Skip to main content

nodedb_fts/search/
bm25_search.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! BM25 search over the FtsIndex with AND-first OR-fallback and NOT-term
4//! exclusion.
5//!
6//! ## AND-first / OR-fallback
7//!
8//! A multi-word query `rust programming` in AND mode matches documents
9//! holding every word (a word matches through any of its synonyms). The
10//! top-k is the true top-k of those documents, whatever `top_k` is. When no
11//! admitted document holds every word, the query falls back to OR with
12//! coverage-scaled scores.
13//!
14//! ## NOT operator
15//!
16//! `rust NOT python` and `rust -python` are equivalent. The query parser
17//! splits the input into positive and negative term lists. BM25 scoring runs
18//! on positive terms only. A document holding any negative term is excluded
19//! before scoring, so the top-k cut counts only surviving documents.
20//! Negative terms do not affect BM25 scores.
21//!
22//! Synonym expansion applies to both positive and negative term lists, so
23//! `rust NOT db` also excludes documents that contain synonym expansions of
24//! `db` (e.g. `database`, `datastore`).
25
26use nodedb_types::SurrogateBitmap;
27
28use crate::backend::FtsBackend;
29use crate::index::FtsIndex;
30use crate::index::error::FtsIndexError;
31use crate::posting::{QueryMode, TextSearchResult};
32use crate::scope::IndexScope;
33use crate::search::bmw::scorer::{BmwInput, bmw_score};
34use crate::search::match_mode::staged_candidates;
35use crate::search::query_parser::parse_query;
36use crate::search::query_terms::TextQuery;
37use crate::search::staged::StagedView;
38
39/// Query and tuning parameters for a BM25 search.
40///
41/// The `(database_id, tid, index)` scope is passed separately so the
42/// same struct can be shared by callers that hold the tenant id as either a
43/// raw `u64` (this crate) or a strongly-typed `TenantId` (the Origin wrapper).
44pub struct FtsSearchParams<'a> {
45    /// Raw query string (may contain `NOT <term>` / `-<term>` negation).
46    pub query: &'a str,
47    /// Maximum number of results to return.
48    pub top_k: usize,
49    /// When `true`, unmatched terms fall back to fuzzy (Levenshtein) lookup.
50    pub fuzzy_enabled: bool,
51    /// Boolean combination mode for multi-term queries (AND or OR).
52    pub mode: QueryMode,
53    /// Optional surrogate bitmap restricting the candidate set before scoring.
54    pub prefilter: Option<&'a SurrogateBitmap>,
55}
56
57impl<B: FtsBackend> FtsIndex<B> {
58    /// Search one index with explicit boolean mode, fuzzy, and optional prefilter.
59    ///
60    /// Analyzer, fuzzy default, and synonyms come from the index's
61    /// collection. Postings and BM25 stats come from the index itself.
62    ///
63    /// Supports `NOT <term>` and `-<term>` negation in the query string.
64    /// Returns `Err(FtsIndexError::InvalidQuery)` for ill-formed queries such
65    /// as NOT-only queries or unsupported parenthesised groups.
66    pub fn search<'a>(
67        &self,
68        database_id: u64,
69        tid: u64,
70        index: impl Into<IndexScope<'a>>,
71        params: FtsSearchParams<'_>,
72    ) -> Result<Vec<TextSearchResult>, FtsIndexError<B::Error>> {
73        self.search_staged(database_id, tid, index, params, None)
74    }
75
76    /// [`Self::search`] inside an open transaction: indexed documents the
77    /// transaction hides do not match, and its staged documents compete in
78    /// the same ranking under the same query semantics. `prefilter` bounds
79    /// staged documents too.
80    ///
81    /// Results are ordered by score descending, then surrogate ascending.
82    pub fn search_staged<'a>(
83        &self,
84        database_id: u64,
85        tid: u64,
86        index: impl Into<IndexScope<'a>>,
87        params: FtsSearchParams<'_>,
88        staged: Option<&StagedView>,
89    ) -> Result<Vec<TextSearchResult>, FtsIndexError<B::Error>> {
90        let index = index.into();
91        let FtsSearchParams {
92            query,
93            top_k,
94            fuzzy_enabled,
95            mode,
96            prefilter,
97        } = params;
98        if top_k == 0 {
99            // The query is still parsed: an ill-formed one is an error at
100            // any limit.
101            parse_query(query)?;
102            return Ok(Vec::new());
103        }
104        let text_query = TextQuery {
105            query,
106            fuzzy_enabled,
107            mode,
108        };
109        let Some(resolved) = self.resolve_query(database_id, tid, index, text_query, staged)?
110        else {
111            return Ok(Vec::new());
112        };
113
114        let mut deny = resolved.negated.clone();
115        if let Some(view) = staged {
116            deny.union_in_place(view.hidden());
117        }
118        let index_visible = staged.is_none_or(|view| !view.hides_all());
119        let staged_docs = staged_candidates(&resolved, staged, prefilter, &self.bm25_params);
120        let match_mode = resolved.match_mode(index_visible, prefilter, &deny, &staged_docs);
121        let allow = match &match_mode {
122            super::match_mode::MatchMode::All(docs) => Some(docs),
123            super::match_mode::MatchMode::Coverage | super::match_mode::MatchMode::Any => prefilter,
124        };
125
126        let mut hits: Vec<TextSearchResult> = Vec::new();
127        if index_visible {
128            let heap = bmw_score(&BmwInput {
129                terms: &resolved.blocks,
130                term_groups: &resolved.term_groups,
131                groups: resolved.groups,
132                total_docs: resolved.total_docs,
133                avg_doc_len: resolved.avg_doc_len,
134                params: &self.bm25_params,
135                top_k,
136                allow,
137                deny: Some(&deny),
138                combine: match_mode.combine(),
139            });
140            hits.extend(heap.into_sorted().into_iter().map(|doc| TextSearchResult {
141                doc_id: doc.doc_id,
142                score: doc.score,
143                fuzzy: resolved.fuzzy,
144            }));
145        }
146        for (doc_id, contributions) in &staged_docs {
147            if let Some(score) = match_mode.score(&resolved, contributions) {
148                hits.push(TextSearchResult {
149                    doc_id: *doc_id,
150                    score,
151                    fuzzy: resolved.fuzzy,
152                });
153            }
154        }
155        hits.sort_by(|a, b| {
156            b.score
157                .partial_cmp(&a.score)
158                .unwrap_or(std::cmp::Ordering::Equal)
159                .then(a.doc_id.cmp(&b.doc_id))
160        });
161        hits.truncate(top_k);
162        Ok(hits)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use nodedb_types::{Surrogate, SurrogateBitmap};
169
170    use super::FtsSearchParams;
171    use crate::backend::memory::MemoryBackend;
172    use crate::index::FtsIndex;
173    use crate::index::error::FtsIndexError;
174    use crate::posting::QueryMode;
175    use crate::search::query_parser::InvalidQuery;
176    use crate::search::staged::StagedDoc;
177    use crate::test_support::test_governor;
178
179    const DB: u64 = 0;
180    const T: u64 = 1;
181    const D1: Surrogate = Surrogate(1);
182    const D2: Surrogate = Surrogate(2);
183    const D3: Surrogate = Surrogate(3);
184
185    fn make_index() -> FtsIndex<MemoryBackend> {
186        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
187        idx.index_document(
188            DB,
189            T,
190            "docs",
191            D1,
192            "The quick brown fox jumps over the lazy dog",
193        )
194        .unwrap();
195        idx.index_document(DB, T, "docs", D2, "A fast brown dog runs across the field")
196            .unwrap();
197        idx.index_document(DB, T, "docs", D3, "Rust programming language for systems")
198            .unwrap();
199        idx
200    }
201
202    #[test]
203    fn basic_search() {
204        let idx = make_index();
205        let results = idx
206            .search(
207                DB,
208                T,
209                "docs",
210                FtsSearchParams {
211                    query: "brown fox",
212                    top_k: 10,
213                    fuzzy_enabled: false,
214                    mode: QueryMode::And,
215                    prefilter: None,
216                },
217            )
218            .unwrap();
219        assert!(!results.is_empty());
220        assert_eq!(results[0].doc_id, D1);
221    }
222
223    #[test]
224    fn search_with_stemming() {
225        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
226        idx.index_document(DB, T, "docs", D1, "running distributed databases")
227            .unwrap();
228        idx.index_document(DB, T, "docs", D2, "the cat sat on a mat")
229            .unwrap();
230
231        let results = idx
232            .search(
233                DB,
234                T,
235                "docs",
236                FtsSearchParams {
237                    query: "database distribution",
238                    top_k: 10,
239                    fuzzy_enabled: false,
240                    mode: QueryMode::And,
241                    prefilter: None,
242                },
243            )
244            .unwrap();
245        assert!(!results.is_empty());
246        assert_eq!(results[0].doc_id, D1);
247    }
248
249    #[test]
250    fn or_mode() {
251        let idx = make_index();
252        let results = idx
253            .search(
254                DB,
255                T,
256                "docs",
257                FtsSearchParams {
258                    query: "brown fox",
259                    top_k: 10,
260                    fuzzy_enabled: false,
261                    mode: QueryMode::Or,
262                    prefilter: None,
263                },
264            )
265            .unwrap();
266        assert!(results.len() >= 2);
267    }
268
269    #[test]
270    fn and_mode_filters() {
271        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
272        idx.index_document(DB, T, "docs", D1, "Rust programming language")
273            .unwrap();
274        idx.index_document(DB, T, "docs", D2, "Python programming language")
275            .unwrap();
276
277        let results = idx
278            .search(
279                DB,
280                T,
281                "docs",
282                FtsSearchParams {
283                    query: "rust programming",
284                    top_k: 10,
285                    fuzzy_enabled: false,
286                    mode: QueryMode::And,
287                    prefilter: None,
288                },
289            )
290            .unwrap();
291        assert_eq!(results.len(), 1);
292        assert_eq!(results[0].doc_id, D1);
293    }
294
295    #[test]
296    fn and_fallback_to_or() {
297        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
298        idx.index_document(DB, T, "docs", D1, "rust programming language")
299            .unwrap();
300        idx.index_document(DB, T, "docs", D2, "python programming language")
301            .unwrap();
302
303        let results = idx
304            .search(
305                DB,
306                T,
307                "docs",
308                FtsSearchParams {
309                    query: "rust python",
310                    top_k: 10,
311                    fuzzy_enabled: false,
312                    mode: QueryMode::And,
313                    prefilter: None,
314                },
315            )
316            .unwrap();
317        assert_eq!(results.len(), 2);
318        for r in &results {
319            assert!(r.score > 0.0);
320        }
321    }
322
323    #[test]
324    fn and_no_fallback_when_results_exist() {
325        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
326        idx.index_document(DB, T, "docs", D1, "rust programming language")
327            .unwrap();
328        idx.index_document(DB, T, "docs", D2, "python programming language")
329            .unwrap();
330
331        let results = idx
332            .search(
333                DB,
334                T,
335                "docs",
336                FtsSearchParams {
337                    query: "rust programming",
338                    top_k: 10,
339                    fuzzy_enabled: false,
340                    mode: QueryMode::And,
341                    prefilter: None,
342                },
343            )
344            .unwrap();
345        assert_eq!(results.len(), 1);
346        assert_eq!(results[0].doc_id, D1);
347    }
348
349    #[test]
350    fn empty_query() {
351        let idx = make_index();
352        let results = idx
353            .search(
354                DB,
355                T,
356                "docs",
357                FtsSearchParams {
358                    query: "the a is",
359                    top_k: 10,
360                    fuzzy_enabled: false,
361                    mode: QueryMode::And,
362                    prefilter: None,
363                },
364            )
365            .unwrap();
366        assert!(results.is_empty());
367    }
368
369    #[test]
370    fn collections_isolated() {
371        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
372        idx.index_document(DB, T, "col_a", D1, "alpha bravo charlie")
373            .unwrap();
374        idx.index_document(DB, T, "col_b", D1, "delta echo foxtrot")
375            .unwrap();
376
377        assert_eq!(
378            idx.search(
379                DB,
380                T,
381                "col_a",
382                FtsSearchParams {
383                    query: "alpha",
384                    top_k: 10,
385                    fuzzy_enabled: false,
386                    mode: QueryMode::And,
387                    prefilter: None
388                }
389            )
390            .unwrap()
391            .len(),
392            1
393        );
394        assert!(
395            idx.search(
396                DB,
397                T,
398                "col_b",
399                FtsSearchParams {
400                    query: "alpha",
401                    top_k: 10,
402                    fuzzy_enabled: false,
403                    mode: QueryMode::And,
404                    prefilter: None
405                }
406            )
407            .unwrap()
408            .is_empty()
409        );
410    }
411
412    #[test]
413    fn fuzzy_search() {
414        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
415        idx.index_document(DB, T, "docs", D1, "distributed database systems")
416            .unwrap();
417
418        let results = idx
419            .search(
420                DB,
421                T,
422                "docs",
423                FtsSearchParams {
424                    query: "databse",
425                    top_k: 10,
426                    fuzzy_enabled: true,
427                    mode: QueryMode::And,
428                    prefilter: None,
429                },
430            )
431            .unwrap();
432        assert!(!results.is_empty());
433        assert!(results[0].fuzzy);
434    }
435
436    #[test]
437    fn phrase_boost_consecutive() {
438        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
439        idx.index_document(DB, T, "docs", D1, "the quick brown fox jumped")
440            .unwrap();
441        idx.index_document(DB, T, "docs", D2, "a brown dog chased a fox")
442            .unwrap();
443
444        let results = idx
445            .search(
446                DB,
447                T,
448                "docs",
449                FtsSearchParams {
450                    query: "brown fox",
451                    top_k: 10,
452                    fuzzy_enabled: false,
453                    mode: QueryMode::Or,
454                    prefilter: None,
455                },
456            )
457            .unwrap();
458        assert!(results.len() >= 2);
459        assert_eq!(results[0].doc_id, D1);
460    }
461
462    #[test]
463    fn phrase_boost_no_effect_single_term() {
464        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
465        idx.index_document(DB, T, "docs", D1, "hello world")
466            .unwrap();
467
468        let results = idx
469            .search(
470                DB,
471                T,
472                "docs",
473                FtsSearchParams {
474                    query: "hello",
475                    top_k: 10,
476                    fuzzy_enabled: false,
477                    mode: QueryMode::And,
478                    prefilter: None,
479                },
480            )
481            .unwrap();
482        assert_eq!(results.len(), 1);
483    }
484
485    #[test]
486    fn tenants_isolated() {
487        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
488        idx.index_document(DB, 1, "docs", D1, "alpha bravo")
489            .unwrap();
490        idx.index_document(DB, 2, "docs", D1, "charlie delta")
491            .unwrap();
492
493        let r1 = idx
494            .search(
495                DB,
496                1,
497                "docs",
498                FtsSearchParams {
499                    query: "alpha",
500                    top_k: 10,
501                    fuzzy_enabled: false,
502                    mode: QueryMode::And,
503                    prefilter: None,
504                },
505            )
506            .unwrap();
507        let r2 = idx
508            .search(
509                DB,
510                2,
511                "docs",
512                FtsSearchParams {
513                    query: "alpha",
514                    top_k: 10,
515                    fuzzy_enabled: false,
516                    mode: QueryMode::And,
517                    prefilter: None,
518                },
519            )
520            .unwrap();
521        assert_eq!(r1.len(), 1);
522        assert!(r2.is_empty());
523    }
524
525    #[test]
526    fn prefilter_excludes_non_member_surrogates() {
527        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
528
529        idx.index_document(DB, T, "docs", D1, "rust language system")
530            .unwrap();
531        idx.index_document(DB, T, "docs", D2, "rust rust rust rust rust")
532            .unwrap();
533        idx.index_document(DB, T, "docs", D3, "rust rust rust rust rust rust")
534            .unwrap();
535
536        let mut bm = SurrogateBitmap::new();
537        bm.insert(D1);
538
539        let results = idx
540            .search(
541                DB,
542                T,
543                "docs",
544                FtsSearchParams {
545                    query: "rust",
546                    top_k: 10,
547                    fuzzy_enabled: false,
548                    mode: QueryMode::And,
549                    prefilter: Some(&bm),
550                },
551            )
552            .unwrap();
553
554        assert_eq!(results.len(), 1, "only D1 should be returned");
555        assert_eq!(results[0].doc_id, D1);
556
557        assert!(
558            !results.iter().any(|r| r.doc_id == D2),
559            "D2 must be excluded"
560        );
561        assert!(
562            !results.iter().any(|r| r.doc_id == D3),
563            "D3 must be excluded"
564        );
565
566        let all_results = idx
567            .search(
568                DB,
569                T,
570                "docs",
571                FtsSearchParams {
572                    query: "rust",
573                    top_k: 10,
574                    fuzzy_enabled: false,
575                    mode: QueryMode::And,
576                    prefilter: None,
577                },
578            )
579            .unwrap();
580        assert_eq!(all_results.len(), 3, "all docs returned without prefilter");
581        assert!(
582            all_results[0].doc_id == D2 || all_results[0].doc_id == D3,
583            "D2 or D3 should lead without prefilter (higher tf)"
584        );
585
586        let empty_bm = SurrogateBitmap::new();
587        let empty_results = idx
588            .search(
589                DB,
590                T,
591                "docs",
592                FtsSearchParams {
593                    query: "rust",
594                    top_k: 10,
595                    fuzzy_enabled: false,
596                    mode: QueryMode::And,
597                    prefilter: Some(&empty_bm),
598                },
599            )
600            .unwrap();
601        assert!(empty_results.is_empty(), "empty prefilter → no results");
602
603        let mut bm23 = SurrogateBitmap::new();
604        bm23.insert(D2);
605        bm23.insert(D3);
606        let results23 = idx
607            .search(
608                DB,
609                T,
610                "docs",
611                FtsSearchParams {
612                    query: "rust",
613                    top_k: 10,
614                    fuzzy_enabled: false,
615                    mode: QueryMode::And,
616                    prefilter: Some(&bm23),
617                },
618            )
619            .unwrap();
620        assert_eq!(results23.len(), 2);
621        assert!(!results23.iter().any(|r| r.doc_id == D1));
622    }
623
624    // ── NOT operator tests ────────────────────────────────────────────────────
625
626    #[test]
627    fn not_keyword_excludes_documents() {
628        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
629        // D1: rust + python, D2: rust + ruby, D3: python + ruby
630        idx.index_document(DB, T, "docs", D1, "rust python programming")
631            .unwrap();
632        idx.index_document(DB, T, "docs", D2, "rust ruby programming")
633            .unwrap();
634        idx.index_document(DB, T, "docs", D3, "python ruby programming")
635            .unwrap();
636
637        let results = idx
638            .search(
639                DB,
640                T,
641                "docs",
642                FtsSearchParams {
643                    query: "rust NOT python",
644                    top_k: 10,
645                    fuzzy_enabled: false,
646                    mode: QueryMode::And,
647                    prefilter: None,
648                },
649            )
650            .unwrap();
651        // Must include D2 (rust, no python), must not include D1 (has python).
652        assert!(
653            results.iter().any(|r| r.doc_id == D2),
654            "D2 (rust+ruby) must be in results"
655        );
656        assert!(
657            !results.iter().any(|r| r.doc_id == D1),
658            "D1 (rust+python) must be excluded"
659        );
660    }
661
662    #[test]
663    fn dash_prefix_excludes_documents() {
664        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
665        idx.index_document(DB, T, "docs", D1, "rust python programming")
666            .unwrap();
667        idx.index_document(DB, T, "docs", D2, "rust ruby programming")
668            .unwrap();
669        idx.index_document(DB, T, "docs", D3, "python ruby programming")
670            .unwrap();
671
672        let results = idx
673            .search(
674                DB,
675                T,
676                "docs",
677                FtsSearchParams {
678                    query: "rust -python",
679                    top_k: 10,
680                    fuzzy_enabled: false,
681                    mode: QueryMode::And,
682                    prefilter: None,
683                },
684            )
685            .unwrap();
686        assert!(results.iter().any(|r| r.doc_id == D2));
687        assert!(!results.iter().any(|r| r.doc_id == D1));
688    }
689
690    #[test]
691    fn multiple_not_excludes_all_negated() {
692        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
693        idx.index_document(DB, T, "docs", D1, "rust python programming")
694            .unwrap();
695        idx.index_document(DB, T, "docs", D2, "rust ruby programming")
696            .unwrap();
697        idx.index_document(DB, T, "docs", D3, "rust systems programming")
698            .unwrap();
699
700        let results = idx
701            .search(
702                DB,
703                T,
704                "docs",
705                FtsSearchParams {
706                    query: "rust NOT python NOT ruby",
707                    top_k: 10,
708                    fuzzy_enabled: false,
709                    mode: QueryMode::And,
710                    prefilter: None,
711                },
712            )
713            .unwrap();
714        // Only D3 has neither python nor ruby.
715        assert!(results.iter().any(|r| r.doc_id == D3));
716        assert!(!results.iter().any(|r| r.doc_id == D1));
717        assert!(!results.iter().any(|r| r.doc_id == D2));
718    }
719
720    #[test]
721    fn not_nonexistent_term_returns_all_positives() {
722        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
723        idx.index_document(DB, T, "docs", D1, "rust programming")
724            .unwrap();
725        idx.index_document(DB, T, "docs", D2, "rust systems")
726            .unwrap();
727
728        let results_plain = idx
729            .search(
730                DB,
731                T,
732                "docs",
733                FtsSearchParams {
734                    query: "rust",
735                    top_k: 10,
736                    fuzzy_enabled: false,
737                    mode: QueryMode::And,
738                    prefilter: None,
739                },
740            )
741            .unwrap();
742        let results_not = idx
743            .search(
744                DB,
745                T,
746                "docs",
747                FtsSearchParams {
748                    query: "rust NOT nonexistentxyz",
749                    top_k: 10,
750                    fuzzy_enabled: false,
751                    mode: QueryMode::And,
752                    prefilter: None,
753                },
754            )
755            .unwrap();
756
757        let plain_ids: std::collections::HashSet<Surrogate> =
758            results_plain.iter().map(|r| r.doc_id).collect();
759        let not_ids: std::collections::HashSet<Surrogate> =
760            results_not.iter().map(|r| r.doc_id).collect();
761        assert_eq!(
762            plain_ids, not_ids,
763            "NOT with nonexistent term must not remove any docs"
764        );
765    }
766
767    #[test]
768    fn negative_only_returns_invalid_query_error() {
769        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
770        idx.index_document(DB, T, "docs", D1, "python programming")
771            .unwrap();
772
773        let err = idx
774            .search(
775                DB,
776                T,
777                "docs",
778                FtsSearchParams {
779                    query: "NOT python",
780                    top_k: 10,
781                    fuzzy_enabled: false,
782                    mode: QueryMode::And,
783                    prefilter: None,
784                },
785            )
786            .unwrap_err();
787        assert!(
788            matches!(err, FtsIndexError::InvalidQuery(InvalidQuery::NegativeOnly)),
789            "expected InvalidQuery(NegativeOnly), got {err:?}"
790        );
791    }
792
793    #[test]
794    fn parentheses_after_not_returns_invalid_query_error() {
795        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
796        idx.index_document(DB, T, "docs", D1, "rust programming")
797            .unwrap();
798
799        let err = idx
800            .search(
801                DB,
802                T,
803                "docs",
804                FtsSearchParams {
805                    query: "rust NOT (python OR ruby)",
806                    top_k: 10,
807                    fuzzy_enabled: false,
808                    mode: QueryMode::And,
809                    prefilter: None,
810                },
811            )
812            .unwrap_err();
813        assert!(
814            matches!(
815                err,
816                FtsIndexError::InvalidQuery(InvalidQuery::ParenthesesNotSupported)
817            ),
818            "expected InvalidQuery(ParenthesesNotSupported), got {err:?}"
819        );
820    }
821
822    // ── top-k semantics ───────────────────────────────────────────────────────
823
824    fn ranked(
825        idx: &FtsIndex<MemoryBackend>,
826        query: &str,
827        top_k: usize,
828        mode: QueryMode,
829        prefilter: Option<&SurrogateBitmap>,
830        staged: Option<&super::StagedView>,
831    ) -> Vec<Surrogate> {
832        idx.search_staged(
833            DB,
834            T,
835            "docs",
836            FtsSearchParams {
837                query,
838                top_k,
839                fuzzy_enabled: false,
840                mode,
841                prefilter,
842            },
843            staged,
844        )
845        .unwrap()
846        .into_iter()
847        .map(|r| r.doc_id)
848        .collect()
849    }
850
851    /// The best `rust` documents hold `python`. Negation drops them before
852    /// the cut, so `LIMIT 1` still returns the surviving document.
853    #[test]
854    fn not_terms_are_excluded_before_the_limit() {
855        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
856        idx.index_document(DB, T, "docs", D1, "rust rust rust python")
857            .unwrap();
858        idx.index_document(DB, T, "docs", D2, "rust rust rust python")
859            .unwrap();
860        idx.index_document(DB, T, "docs", D3, "rust golang compiler toolchain")
861            .unwrap();
862        assert_eq!(
863            ranked(&idx, "rust -python", 1, QueryMode::And, None, None),
864            vec![D3]
865        );
866        assert_eq!(
867            ranked(&idx, "rust -python", 2, QueryMode::Or, None, None),
868            vec![D3]
869        );
870    }
871
872    /// Many documents out-score the single AND match on one word. The AND
873    /// match is found whatever the limit, and the query does not fall back.
874    #[test]
875    fn and_match_is_found_past_many_single_word_out_scorers() {
876        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
877        for i in 1..=40u32 {
878            idx.index_document(DB, T, "docs", Surrogate(i), "alpha alpha alpha alpha")
879                .unwrap();
880        }
881        for i in 41..=80u32 {
882            idx.index_document(DB, T, "docs", Surrogate(i), "bravo bravo bravo bravo")
883                .unwrap();
884        }
885        let both = Surrogate(81);
886        idx.index_document(DB, T, "docs", both, "alpha bravo filler words here")
887            .unwrap();
888        for limit in [1, 3, 10, usize::MAX] {
889            assert_eq!(
890                ranked(&idx, "alpha bravo", limit, QueryMode::And, None, None),
891                vec![both],
892                "limit {limit}"
893            );
894        }
895    }
896
897    /// The AND decision reads only the admitted rows. An AND match outside
898    /// the prefilter does not stop the fallback inside it, and an AND match
899    /// inside it keeps AND semantics.
900    #[test]
901    fn and_mode_inside_a_prefilter() {
902        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
903        idx.index_document(DB, T, "docs", D1, "alpha bravo")
904            .unwrap();
905        idx.index_document(DB, T, "docs", D2, "alpha charlie")
906            .unwrap();
907        idx.index_document(DB, T, "docs", D3, "bravo delta")
908            .unwrap();
909
910        let mut without_match = SurrogateBitmap::new();
911        without_match.insert(D2);
912        without_match.insert(D3);
913        let mut fallback = ranked(
914            &idx,
915            "alpha bravo",
916            10,
917            QueryMode::And,
918            Some(&without_match),
919            None,
920        );
921        fallback.sort();
922        assert_eq!(fallback, vec![D2, D3], "no admitted AND match: OR fallback");
923
924        let mut with_match = without_match.clone();
925        with_match.insert(D1);
926        assert_eq!(
927            ranked(
928                &idx,
929                "alpha bravo",
930                10,
931                QueryMode::And,
932                Some(&with_match),
933                None
934            ),
935            vec![D1],
936            "an admitted AND match keeps AND semantics"
937        );
938    }
939
940    /// A staged update that removes the best hit leaves the limit filled by
941    /// the next document, and a staged document competes in the ranking.
942    #[test]
943    fn staged_rows_are_ranked_before_the_cut() {
944        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
945        idx.index_document(DB, T, "docs", D1, "rust rust rust rust")
946            .unwrap();
947        idx.index_document(DB, T, "docs", D2, "rust rust lang")
948            .unwrap();
949        idx.index_document(DB, T, "docs", D3, "rust tooling compiler words")
950            .unwrap();
951
952        let mut hidden = SurrogateBitmap::new();
953        hidden.insert(D1);
954        let removed_top = super::StagedView::new(
955            hidden,
956            false,
957            vec![StagedDoc {
958                doc_id: D1,
959                tokens: vec!["python".into()],
960            }],
961        );
962        assert_eq!(
963            ranked(&idx, "rust", 2, QueryMode::And, None, Some(&removed_top)),
964            vec![D2, D3]
965        );
966
967        let new_best = super::StagedView::new(
968            SurrogateBitmap::new(),
969            false,
970            vec![StagedDoc {
971                doc_id: Surrogate(9),
972                tokens: vec!["rust".into(); 6],
973            }],
974        );
975        assert_eq!(
976            ranked(&idx, "rust", 1, QueryMode::And, None, Some(&new_best))[0],
977            Surrogate(9)
978        );
979    }
980
981    /// A staged document is scored with AND semantics: holding one of two
982    /// words does not match while an AND match exists.
983    #[test]
984    fn staged_rows_use_and_semantics() {
985        let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
986        idx.index_document(DB, T, "docs", D1, "alpha bravo")
987            .unwrap();
988        let view = super::StagedView::new(
989            SurrogateBitmap::new(),
990            false,
991            vec![
992                StagedDoc {
993                    doc_id: Surrogate(8),
994                    tokens: vec!["alpha".into(), "alpha".into()],
995                },
996                StagedDoc {
997                    doc_id: Surrogate(9),
998                    tokens: vec!["alpha".into(), "bravo".into()],
999                },
1000            ],
1001        );
1002        let mut hits = ranked(&idx, "alpha bravo", 10, QueryMode::And, None, Some(&view));
1003        hits.sort();
1004        assert_eq!(hits, vec![D1, Surrogate(9)]);
1005
1006        let negated = ranked(&idx, "alpha -bravo", 10, QueryMode::And, None, Some(&view));
1007        assert_eq!(negated, vec![Surrogate(8)]);
1008    }
1009}