Skip to main content

hermes_core/query/
boolean.rs

1//! Boolean query with MUST, SHOULD, and MUST_NOT clauses
2
3use std::sync::Arc;
4
5use crate::segment::SegmentReader;
6use crate::structures::TERMINATED;
7use crate::{DocId, Score};
8
9use super::planner::{
10    build_combined_bitset, build_sparse_bmp_results, build_sparse_bmp_results_filtered,
11    build_sparse_maxscore_executor, cap_terms, chain_predicates, combine_sparse_results,
12    compute_idf, extract_all_sparse_infos, finish_chunked_text_maxscore, finish_text_maxscore,
13    prepare_per_field_grouping, prepare_text_maxscore, text_maxscore_allowed,
14};
15use super::{CountFuture, EmptyScorer, GlobalStats, Query, Scorer, ScorerFuture};
16
17/// Boolean query with MUST, SHOULD, and MUST_NOT clauses
18///
19/// When all clauses are SHOULD term queries on the same field, automatically
20/// uses MaxScore optimization for efficient top-k retrieval.
21#[derive(Clone)]
22pub struct BooleanQuery {
23    pub must: Vec<Arc<dyn Query>>,
24    pub should: Vec<Arc<dyn Query>>,
25    pub must_not: Vec<Arc<dyn Query>>,
26    /// Optional global statistics for cross-segment IDF
27    global_stats: Option<Arc<GlobalStats>>,
28    /// Proximity rescoring of the text MaxScore result (SHOULD terms in
29    /// query order); `None` = off.
30    proximity: Option<super::ProximityConfig>,
31    /// Approximate text MaxScore: threshold scaled by `1 / heap_factor`
32    /// (> 1 prunes beyond rank safety). 1.0 = exact.
33    text_heap_factor: f32,
34    /// Keep only the rarest `max_terms` SHOULD text terms of a field group
35    /// (0 = all): long-query cap.
36    max_terms: usize,
37}
38
39fn shared_or_extract_sparse_infos<'a>(
40    plan: Option<&'a Arc<super::bmp::LspSegmentPlan>>,
41    should: &[Arc<dyn Query>],
42) -> Option<std::borrow::Cow<'a, [super::SparseTermQueryInfo]>> {
43    plan.map(|plan| std::borrow::Cow::Borrowed(plan.infos.as_ref()))
44        .or_else(|| extract_all_sparse_infos(should).map(std::borrow::Cow::Owned))
45}
46
47impl std::fmt::Debug for BooleanQuery {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("BooleanQuery")
50            .field("must_count", &self.must.len())
51            .field("should_count", &self.should.len())
52            .field("must_not_count", &self.must_not.len())
53            .field("has_global_stats", &self.global_stats.is_some())
54            .field("proximity", &self.proximity)
55            .finish()
56    }
57}
58
59impl std::fmt::Display for BooleanQuery {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "Boolean(")?;
62        let mut first = true;
63        for q in &self.must {
64            if !first {
65                write!(f, " ")?;
66            }
67            write!(f, "+{}", q)?;
68            first = false;
69        }
70        for q in &self.should {
71            if !first {
72                write!(f, " ")?;
73            }
74            write!(f, "{}", q)?;
75            first = false;
76        }
77        for q in &self.must_not {
78            if !first {
79                write!(f, " ")?;
80            }
81            write!(f, "-{}", q)?;
82            first = false;
83        }
84        if let Some(proximity) = &self.proximity {
85            write!(f, " ~proximity({}, {})", proximity.weight, proximity.window)?;
86        }
87        if self.text_heap_factor > 1.0 {
88            write!(f, " ~heap({})", self.text_heap_factor)?;
89        }
90        if self.max_terms > 0 {
91            write!(f, " ~max_terms({})", self.max_terms)?;
92        }
93        write!(f, ")")
94    }
95}
96
97impl Default for BooleanQuery {
98    fn default() -> Self {
99        Self {
100            must: Vec::new(),
101            should: Vec::new(),
102            must_not: Vec::new(),
103            global_stats: None,
104            proximity: None,
105            text_heap_factor: 1.0,
106            max_terms: 0,
107        }
108    }
109}
110
111impl BooleanQuery {
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    pub fn must(mut self, query: impl Query + 'static) -> Self {
117        self.must.push(Arc::new(query));
118        self
119    }
120
121    pub fn should(mut self, query: impl Query + 'static) -> Self {
122        self.should.push(Arc::new(query));
123        self
124    }
125
126    pub fn must_not(mut self, query: impl Query + 'static) -> Self {
127        self.must_not.push(Arc::new(query));
128        self
129    }
130
131    /// Set global statistics for cross-segment IDF
132    pub fn with_global_stats(mut self, stats: Arc<GlobalStats>) -> Self {
133        self.global_stats = Some(stats);
134        self
135    }
136
137    /// Rescore the text MaxScore top candidates with term proximity
138    /// (`docs`: `query::proximity`). Applies when the SHOULD clauses are text
139    /// terms of one field, in query order.
140    pub fn with_proximity(mut self, config: super::ProximityConfig) -> Self {
141        self.proximity = config.is_active().then_some(config);
142        self
143    }
144
145    /// Approximate text MaxScore (threshold × `1 / heap_factor`); values
146    /// at or below 1 keep the exact, rank-safe traversal.
147    pub fn with_text_heap_factor(mut self, heap_factor: f32) -> Self {
148        self.text_heap_factor = if heap_factor > 1.0 { heap_factor } else { 1.0 };
149        self
150    }
151
152    /// Cap the text terms scored per field group to the `max_terms` rarest
153    /// (highest idf) ones; 0 = no cap.
154    pub fn with_max_terms(mut self, max_terms: usize) -> Self {
155        self.max_terms = max_terms;
156        self
157    }
158}
159
160/// Build a SHOULD-only scorer from a vec of optimized scorers.
161fn build_should_scorer<'a>(scorers: Vec<Box<dyn Scorer + 'a>>) -> Box<dyn Scorer + 'a> {
162    if scorers.is_empty() {
163        return Box::new(EmptyScorer);
164    }
165    if scorers.len() == 1 {
166        return scorers.into_iter().next().unwrap();
167    }
168    let mut scorer = BooleanScorer {
169        must: vec![],
170        should: scorers,
171        must_not: vec![],
172        current_doc: 0,
173    };
174    scorer.current_doc = scorer.find_next_match();
175    Box::new(scorer)
176}
177
178// ── Planner macro ────────────────────────────────────────────────────────
179//
180// Unified planner for both async and sync paths.  Parameterised on:
181//   $scorer_fn      – scorer_with_options | scorer_sync_with_options
182//   $get_postings_fn – get_postings | get_postings_sync
183//   $execute_fn     – execute | execute_sync
184//   $($aw)*         – .await  (present for async, absent for sync)
185//
186// Decision order:
187//   1. Single-clause unwrap
188//   2. Pure OR → text MaxScore | sparse MaxScore | per-field MaxScore
189//   3. Filter push-down → predicate-aware sparse MaxScore | PredicatedScorer
190//   4. Standard BooleanScorer fallback
191macro_rules! boolean_plan {
192    ($must:expr, $should:expr, $must_not:expr, $global_stats:expr, $proximity:expr, $text_tuning:expr,
193     $reader:expr, $limit:expr, $scorer_options:expr,
194     $scorer_fn:ident, $get_postings_fn:ident, $execute_fn:ident
195     $(, $aw:tt)*) => {{
196        let must: &[Arc<dyn Query>] = &$must;
197        let should_all: &[Arc<dyn Query>] = &$should;
198        let must_not: &[Arc<dyn Query>] = &$must_not;
199        let global_stats: Option<&Arc<GlobalStats>> = $global_stats;
200        let reader: &SegmentReader = $reader;
201        let limit: usize = $limit;
202        let scorer_options: super::ScorerOptions = $scorer_options;
203
204        // Cap SHOULD clauses to MAX_QUERY_TERMS, but only count queries that need
205        // posting-list cursors. Fast-field predicates (O(1) per doc) are exempt.
206        let should_capped: Vec<Arc<dyn Query>>;
207        let should: &[Arc<dyn Query>] = if should_all.len() > super::MAX_QUERY_TERMS {
208            let is_predicate: Vec<bool> = should_all
209                .iter()
210                .map(|q| q.is_filter() || q.as_doc_predicate(reader).is_some())
211                .collect();
212            let cursor_count = is_predicate.iter().filter(|&&p| !p).count();
213
214            if cursor_count > super::MAX_QUERY_TERMS {
215                let mut kept = Vec::with_capacity(should_all.len());
216                let mut cursor_kept = 0usize;
217                for (q, &is_pred) in should_all.iter().zip(is_predicate.iter()) {
218                    if is_pred {
219                        kept.push(q.clone());
220                    } else if cursor_kept < super::MAX_QUERY_TERMS {
221                        kept.push(q.clone());
222                        cursor_kept += 1;
223                    }
224                }
225                log::debug!(
226                    "BooleanQuery: capping cursor SHOULD from {} to {} ({} fast-field predicates exempt)",
227                    cursor_count,
228                    super::MAX_QUERY_TERMS,
229                    kept.len() - cursor_kept,
230                );
231                should_capped = kept;
232                &should_capped
233            } else {
234                log::debug!(
235                    "BooleanQuery: {} SHOULD clauses OK ({} need cursors, {} fast-field predicates)",
236                    should_all.len(),
237                    cursor_count,
238                    should_all.len() - cursor_count,
239                );
240                should_all
241            }
242        } else {
243            should_all
244        };
245
246        // ── 1. Single-clause optimisation ────────────────────────────────
247        if must_not.is_empty() {
248            if must.len() == 1 && should.is_empty() {
249                return must[0].$scorer_fn(reader, limit, scorer_options) $(.  $aw)* ;
250            }
251            if should.len() == 1 && must.is_empty() {
252                return should[0].$scorer_fn(reader, limit, scorer_options) $(. $aw)* ;
253            }
254        }
255
256        // ── 2. Pure OR → MaxScore optimisations ──────────────────────────
257        if must.is_empty() && must_not.is_empty() && should.len() >= 2 {
258            // 2a. Text MaxScore (single-field, all term queries)
259            if let Some((mut infos, text_field, avg_field_len, num_docs)) =
260                prepare_text_maxscore(should, reader, global_stats)
261                && text_maxscore_allowed(reader, text_field, scorer_options.collect_positions)
262            {
263                let mut posting_lists = Vec::with_capacity(infos.len());
264                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
265                for info in infos.drain(..) {
266                    if let Some(pl) = reader.$get_postings_fn(info.field, &info.term)
267                        $(. $aw)* ?
268                    {
269                        let idf = compute_idf(&pl, info.field, &info.term, num_docs, global_stats) * info.weight;
270                        posting_lists.push((pl, idf));
271                        term_bytes.push(info.term.clone());
272                    }
273                }
274                cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
275                // Chunked field: score chunks, fold to documents with ordinals.
276                if reader.is_chunked_field(text_field) {
277                    return finish_chunked_text_maxscore(
278                        posting_lists, limit, reader, text_field, None,
279                        $proximity.map(|config| (config, term_bytes)),
280                        $text_tuning.0,
281                        scorer_options.shared_threshold.as_ref(),
282                    );
283                }
284                // Seed from the cross-segment floor: this path scores final
285                // per-doc BM25 into a top-`limit` heap, so a floor carried from
286                // an already-searched segment prunes exactly (see
287                // SharedThreshold). The per-field path below stays at 0.0 —
288                // its per-field partial scores are not the final doc score.
289                let shared_threshold = std::cell::Cell::new(scorer_options.initial_threshold);
290                return finish_text_maxscore(
291                    posting_lists,
292                    avg_field_len,
293                    reader.doc_lengths(text_field),
294                    limit,
295                    &shared_threshold,
296                    reader,
297                    text_field,
298                    None,
299                    super::Bm25Params::for_field(reader.schema(), text_field),
300                    $proximity.map(|config| (config, term_bytes)),
301                    $text_tuning.0,
302                    scorer_options.shared_threshold.as_ref(),
303                );
304            }
305
306            // 2b. Sparse (single-field, all sparse term queries)
307            // Auto-detect: BMP executor if field has BMP index, else MaxScore
308            if let Some(infos) =
309                shared_or_extract_sparse_infos(scorer_options.lsp_plan.as_ref(), should)
310            {
311                if let Some((raw, info)) =
312                    build_sparse_bmp_results(&infos, reader, limit, &scorer_options)?
313                {
314                    return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
315                }
316                if let Some((executor, info)) =
317                    build_sparse_maxscore_executor(&infos, reader, limit, None)
318                {
319                    let raw = executor.$execute_fn() $(. $aw)* ?;
320                    return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
321                }
322            }
323
324            // 2c. Per-field text MaxScore (multi-field term grouping)
325            if let Some(grouping) = prepare_per_field_grouping(
326                should,
327                reader,
328                limit,
329                global_stats,
330                scorer_options.collect_positions,
331            ) {
332                let mut scorers: Vec<Box<dyn Scorer + '_>> = Vec::new();
333                // Query-local cross-group threshold seeding (see finish_text_maxscore)
334                let shared_threshold = std::cell::Cell::new(0.0f32);
335                for (field, avg_field_len, infos) in &grouping.multi_term_groups {
336                    // Chunked fields: IDF over chunks, not documents.
337                    let corpus_size = reader.text_corpus_size(*field);
338                    let mut posting_lists = Vec::with_capacity(infos.len());
339                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
340                    for info in infos {
341                        if let Some(pl) = reader.$get_postings_fn(info.field, &info.term)
342                            $(. $aw)* ?
343                        {
344                            let idf = compute_idf(
345                                &pl, *field, &info.term, corpus_size, global_stats,
346                            ) * info.weight;
347                            posting_lists.push((pl, idf));
348                        term_bytes.push(info.term.clone());
349                        }
350                    }
351                    cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
352                    if reader.is_chunked_field(*field) {
353                        scorers.push(finish_chunked_text_maxscore(
354                            posting_lists,
355                            grouping.per_field_limit,
356                            reader,
357                            *field,
358                            None,
359                            $proximity.map(|config| (config, term_bytes)),
360                            $text_tuning.0,
361                            scorer_options.shared_threshold.as_ref(),
362                        )?);
363                    } else if !posting_lists.is_empty() {
364                        scorers.push(finish_text_maxscore(
365                            posting_lists,
366                            *avg_field_len,
367                            reader.doc_lengths(*field),
368                            grouping.per_field_limit,
369                            &shared_threshold,
370                            reader,
371                            *field,
372                            None,
373                            super::Bm25Params::for_field(reader.schema(), *field),
374                            $proximity.map(|config| (config, term_bytes)),
375                            $text_tuning.0,
376                            scorer_options.shared_threshold.as_ref(),
377                        )?);
378                    }
379                }
380                for &idx in &grouping.fallback_indices {
381                    scorers.push(should[idx].$scorer_fn(
382                        reader,
383                        limit,
384                        scorer_options.without_threshold(),
385                    ) $(. $aw)* ?);
386                }
387                return Ok(build_should_scorer(scorers));
388            }
389        }
390
391        // ── 3. Filter push-down (MUST + SHOULD) ─────────────────────────
392        //
393        // Position collection no longer disables this path: fast-field
394        // predicates carry no positions to lose and verifier scorers keep
395        // theirs. Only the posting-list bitset shortcut is skipped when
396        // positions are requested, because a bitset cannot report them.
397        if !should.is_empty() && !must.is_empty() {
398            // ── 3-text. Text SHOULD with materializable filters ──────────
399            //
400            // When every SHOULD clause is a text term and the MUST/MUST_NOT
401            // clauses combine into one document bitset (term filters, ranges,
402            // quoted phrases via `PhraseQuery::as_doc_bitset`), the text
403            // MaxScore executors run with the bitset as a predicate: the
404            // top-k is exact over the filtered documents (bounds are unaffected
405            // by a filter), instead of an over-fetched unfiltered top-k that a
406            // PredicatedScorer thins out afterwards. Documents matching only
407            // the filters (score 0) fill the tail when fewer than `limit`
408            // scored documents survive, keeping Boolean semantics.
409            let text_groups: Option<Vec<(crate::Field, Vec<super::TermQueryInfo>)>> = {
410                let mut groups: Vec<(crate::Field, Vec<super::TermQueryInfo>)> = Vec::new();
411                let mut all_text = true;
412                for q in should {
413                    match q.decompose() {
414                        super::QueryDecomposition::TextTerm(info)
415                            if text_maxscore_allowed(
416                                reader, info.field, scorer_options.collect_positions,
417                            ) =>
418                        {
419                            match groups.iter_mut().find(|(f, _)| *f == info.field) {
420                                Some((_, infos)) => infos.push(info),
421                                None => groups.push((info.field, vec![info])),
422                            }
423                        }
424                        _ => {
425                            all_text = false;
426                            break;
427                        }
428                    }
429                }
430                all_text.then_some(groups)
431            };
432            if let Some(groups) = text_groups
433                && let Some(bitset) = build_combined_bitset(must, must_not, reader)
434            {
435                let bitset = std::sync::Arc::new(bitset);
436                let single_field = groups.len() == 1;
437                let group_limit = if single_field {
438                    limit
439                } else {
440                    super::max_candidate_limit(limit)
441                        .min(reader.num_docs() as usize)
442                        .max(1)
443                };
444                // Cross-segment floor only when the group score is the final
445                // document score (single field); per-field partial scores
446                // start at 0.0 like path 2c.
447                let shared_threshold = std::cell::Cell::new(if single_field {
448                    scorer_options.initial_threshold
449                } else {
450                    0.0
451                });
452                let mut scorers: Vec<Box<dyn Scorer + '_>> = Vec::new();
453                let mut found = 0u32;
454                let mut complete = true;
455                for (field, infos) in groups {
456                    let corpus_size = reader.text_corpus_size(field);
457                    let avg_field_len = global_stats
458                        .map(|s| s.avg_field_len(field))
459                        .unwrap_or_else(|| reader.avg_field_len(field));
460                    let mut posting_lists = Vec::with_capacity(infos.len());
461                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
462                    for info in &infos {
463                        if let Some(pl) = reader.$get_postings_fn(field, &info.term) $(. $aw)* ? {
464                            let idf = compute_idf(&pl, field, &info.term, corpus_size, global_stats) * info.weight;
465                            posting_lists.push((pl, idf));
466                        term_bytes.push(info.term.clone());
467                        }
468                    }
469                    cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
470                    let filter = bitset.clone();
471                    let predicate: super::DocPredicate<'_> =
472                        Box::new(move |doc_id| filter.contains(doc_id));
473                    let scorer = if reader.is_chunked_field(field) {
474                        finish_chunked_text_maxscore(
475                            posting_lists, group_limit, reader, field, Some(predicate),
476                            $proximity.map(|config| (config, term_bytes)),
477                            $text_tuning.0,
478                            scorer_options.shared_threshold.as_ref(),
479                        )?
480                    } else {
481                        finish_text_maxscore(
482                            posting_lists,
483                            avg_field_len,
484                            reader.doc_lengths(field),
485                            group_limit,
486                            &shared_threshold,
487                            reader,
488                            field,
489                            Some(predicate),
490                            super::Bm25Params::for_field(reader.schema(), field),
491                            $proximity.map(|config| (config, term_bytes)),
492                            $text_tuning.0,
493                            scorer_options.shared_threshold.as_ref(),
494                        )?
495                    };
496                    let hits = scorer.size_hint();
497                    found = found.saturating_add(hits);
498                    if hits as usize >= group_limit {
499                        complete = false;
500                    }
501                    scorers.push(scorer);
502                }
503                log::debug!(
504                    "BooleanQuery planner: bitset-aware text MaxScore, {} field group(s), \
505                     {} filtered docs, {} scored hits",
506                    scorers.len(),
507                    bitset.count(),
508                    found
509                );
510                let should_scorer = build_should_scorer(scorers);
511                if complete && (found as usize) < limit && bitset.count() > found {
512                    return Ok(Box::new(super::planner::BitsetFillScorer::new(
513                        should_scorer,
514                        bitset,
515                    )));
516                }
517                return Ok(should_scorer);
518            }
519
520            // Pre-check: is SHOULD all-sparse? This determines whether we can
521            // use bitset fallback for MUST clauses that lack fast-field predicates.
522            // For sparse SHOULD, the predicate is pushed into BMP/MaxScore traversal
523            // so all qualifying docs are found. For text SHOULD, we must NOT convert
524            // MUST to a predicate (PredicatedScorer would drop MUST-only docs that
525            // don't match SHOULD), so those go to verifier → BooleanScorer.
526            let should_is_sparse = scorer_options.lsp_plan.is_some()
527                || extract_all_sparse_infos(should).is_some();
528            let bitset_predicates_allowed = should_is_sparse && !scorer_options.collect_positions;
529
530            // 3a. Compile MUST → predicates (O(1)) vs verifier scorers (seek)
531            //
532            // Priority: as_doc_predicate (fast-field O(1)) > as_doc_bitset
533            // (posting-list materialization, O(1) lookup, sparse-SHOULD only)
534            // > verifier scorer (seek).
535            let mut predicates: Vec<super::DocPredicate<'_>> = Vec::new();
536            let mut must_verifiers: Vec<Box<dyn super::Scorer + '_>> = Vec::new();
537            for q in must {
538                if let Some(pred) = q.as_doc_predicate(reader) {
539                    log::debug!("BooleanQuery planner 3a: MUST clause → predicate ({})", q);
540                    predicates.push(pred);
541                } else if bitset_predicates_allowed {
542                    if let Some(bitset) = q.as_doc_bitset(reader) {
543                        log::debug!("BooleanQuery planner 3a: MUST clause → bitset predicate ({})", q);
544                        predicates.push(Box::new(move |doc_id| bitset.contains(doc_id)));
545                    } else {
546                        log::debug!("BooleanQuery planner 3a: MUST clause → verifier scorer ({})", q);
547                        must_verifiers.push(q.$scorer_fn(
548                            reader, limit, scorer_options.without_threshold()
549                        ) $(. $aw)* ?);
550                    }
551                } else {
552                    log::debug!("BooleanQuery planner 3a: MUST clause → verifier scorer ({})", q);
553                    must_verifiers.push(q.$scorer_fn(
554                        reader, limit, scorer_options.without_threshold()
555                    ) $(. $aw)* ?);
556                }
557            }
558            // Compile MUST_NOT → negated predicates vs verifier scorers
559            let mut must_not_verifiers: Vec<Box<dyn super::Scorer + '_>> = Vec::new();
560            for q in must_not {
561                if let Some(pred) = q.as_doc_predicate(reader) {
562                    let negated: super::DocPredicate<'_> =
563                        Box::new(move |doc_id| !pred(doc_id));
564                    predicates.push(negated);
565                } else if bitset_predicates_allowed {
566                    if let Some(bitset) = q.as_doc_bitset(reader) {
567                        log::debug!("BooleanQuery planner 3a: MUST_NOT clause → bitset predicate ({})", q);
568                        predicates.push(Box::new(move |doc_id| !bitset.contains(doc_id)));
569                    } else {
570                        must_not_verifiers.push(q.$scorer_fn(
571                            reader, limit, scorer_options.without_threshold()
572                        ) $(. $aw)* ?);
573                    }
574                } else {
575                    must_not_verifiers.push(q.$scorer_fn(
576                        reader, limit, scorer_options.without_threshold()
577                    ) $(. $aw)* ?);
578                }
579            }
580
581            // 3b. Fast path: pure predicates + sparse SHOULD → BMP or MaxScore w/ predicate
582            if must_verifiers.is_empty()
583                && must_not_verifiers.is_empty()
584                && !predicates.is_empty()
585            {
586                let sparse_infos =
587                    shared_or_extract_sparse_infos(scorer_options.lsp_plan.as_ref(), should);
588                if let Some(infos) = sparse_infos {
589                    // Try BMP with bitset first: build compact bitset from MUST/MUST_NOT
590                    // posting lists (O(M) for term queries) for fast per-slot lookup.
591                    let bitset_result = build_combined_bitset(must, must_not, reader);
592                    if let Some(ref bitset) = bitset_result {
593                        let bitset_pred = |doc_id: crate::DocId| bitset.contains(doc_id);
594                        if let Some((raw, info)) =
595                            build_sparse_bmp_results_filtered(
596                                &infos, reader, limit, &bitset_pred, &scorer_options
597                            )?
598                        {
599                            log::debug!(
600                                "BooleanQuery planner: bitset-aware sparse BMP, {} dims, {} matching docs",
601                                infos.len(),
602                                bitset.count()
603                            );
604                            return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
605                        }
606                    }
607
608                    // Fallback: closure predicate (for queries that don't support bitsets)
609                    let combined = chain_predicates(predicates);
610                    if let Some((raw, info)) =
611                        build_sparse_bmp_results_filtered(
612                            &infos, reader, limit, &*combined, &scorer_options
613                        )?
614                    {
615                        log::debug!(
616                            "BooleanQuery planner: predicate-aware sparse BMP, {} dims",
617                            infos.len()
618                        );
619                        return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
620                    }
621                    // Try MaxScore with predicate
622                    if let Some((executor, info)) =
623                        build_sparse_maxscore_executor(&infos, reader, limit, Some(combined))
624                    {
625                        log::debug!(
626                            "BooleanQuery planner: predicate-aware sparse MaxScore, {} dims",
627                            infos.len()
628                        );
629                        let raw = executor.$execute_fn() $(. $aw)* ?;
630                        return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
631                    }
632                    // predicates consumed — cannot fall through; rebuild them
633                    // (this path only triggers if neither sparse index exists)
634                    // should_is_sparse is true here (we're inside extract_all_sparse_infos)
635                    predicates = Vec::new();
636                    for q in must {
637                        if let Some(pred) = q.as_doc_predicate(reader) {
638                            predicates.push(pred);
639                        } else if let Some(bitset) = q.as_doc_bitset(reader) {
640                            predicates.push(Box::new(move |doc_id| bitset.contains(doc_id)));
641                        }
642                    }
643                    for q in must_not {
644                        if let Some(pred) = q.as_doc_predicate(reader) {
645                            let negated: super::DocPredicate<'_> =
646                                Box::new(move |doc_id| !pred(doc_id));
647                            predicates.push(negated);
648                        } else if let Some(bitset) = q.as_doc_bitset(reader) {
649                            predicates.push(Box::new(move |doc_id| !bitset.contains(doc_id)));
650                        }
651                    }
652                }
653            }
654
655            // 3c. PredicatedScorer fallback. Filters can discard candidates,
656            // so use the same bounded candidate budget as other query paths.
657            let has_filters = !predicates.is_empty()
658                || !must_verifiers.is_empty()
659                || !must_not_verifiers.is_empty();
660            let should_limit = if has_filters {
661                super::max_candidate_limit(limit)
662            } else {
663                limit
664            };
665            let mut should_options = scorer_options.without_threshold();
666            if should_is_sparse {
667                // The outer decomposition built this plan from the complete
668                // sparse SHOULD expression. Filters cannot increase scores,
669                // so retain global γ even when a verifier prevents predicate
670                // push-down. Thresholds still belong to the outer score space
671                // and remain cleared.
672                should_options.lsp_plan = scorer_options.lsp_plan.clone();
673            }
674            let should_scorer = if should.len() == 1 {
675                should[0].$scorer_fn(reader, should_limit, should_options) $(. $aw)* ?
676            } else {
677                let sub = BooleanQuery {
678                    must: Vec::new(),
679                    should: should.to_vec(),
680                    must_not: Vec::new(),
681                    global_stats: global_stats.cloned(),
682                    proximity: $proximity,
683                    text_heap_factor: $text_tuning.0,
684                    max_terms: $text_tuning.1,
685                };
686                sub.$scorer_fn(reader, should_limit, should_options) $(. $aw)* ?
687            };
688
689            let use_predicated =
690                must_verifiers.is_empty() || should_scorer.size_hint() >= limit as u32;
691
692            if use_predicated {
693                log::debug!(
694                    "BooleanQuery planner: PredicatedScorer {} preds + {} must_v + {} must_not_v, \
695                     SHOULD size_hint={}, over_fetch={}",
696                    predicates.len(), must_verifiers.len(), must_not_verifiers.len(),
697                    should_scorer.size_hint(), should_limit
698                );
699                return Ok(Box::new(super::PredicatedScorer::new(
700                    should_scorer, predicates, must_verifiers, must_not_verifiers,
701                )));
702            }
703
704            // size_hint < limit with verifiers → BooleanScorer
705            log::debug!(
706                "BooleanQuery planner: BooleanScorer fallback, size_hint={} < limit={}, \
707                 {} must_v + {} must_not_v",
708                should_scorer.size_hint(), limit,
709                must_verifiers.len(), must_not_verifiers.len()
710            );
711            let mut scorer = BooleanScorer {
712                must: must_verifiers,
713                should: vec![should_scorer],
714                must_not: must_not_verifiers,
715                current_doc: 0,
716            };
717            scorer.current_doc = scorer.find_next_match();
718            return Ok(Box::new(scorer));
719        }
720
721        // ── 4. Standard BooleanScorer fallback ───────────────────────────
722        let mut must_scorers = Vec::with_capacity(must.len());
723        for q in must {
724            must_scorers.push(q.$scorer_fn(
725                reader, limit, scorer_options.without_threshold()
726            ) $(. $aw)* ?);
727        }
728        let mut should_scorers = Vec::with_capacity(should.len());
729        for q in should {
730            should_scorers.push(q.$scorer_fn(
731                reader, limit, scorer_options.without_threshold()
732            ) $(. $aw)* ?);
733        }
734        let mut must_not_scorers = Vec::with_capacity(must_not.len());
735        for q in must_not {
736            must_not_scorers.push(q.$scorer_fn(
737                reader, limit, scorer_options.without_threshold()
738            ) $(. $aw)* ?);
739        }
740        let mut scorer = BooleanScorer {
741            must: must_scorers,
742            should: should_scorers,
743            must_not: must_not_scorers,
744            current_doc: 0,
745        };
746        scorer.current_doc = scorer.find_next_match();
747        Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
748    }};
749}
750
751impl Query for BooleanQuery {
752    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
753        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
754    }
755
756    fn scorer_with_options<'a>(
757        &self,
758        reader: &'a SegmentReader,
759        limit: usize,
760        options: super::ScorerOptions,
761    ) -> ScorerFuture<'a> {
762        let must = self.must.clone();
763        let should = self.should.clone();
764        let must_not = self.must_not.clone();
765        let global_stats = self
766            .global_stats
767            .clone()
768            .or_else(|| options.global_stats.clone());
769        let proximity = self.proximity;
770        let text_tuning = (self.text_heap_factor, self.max_terms);
771        Box::pin(async move {
772            boolean_plan!(
773                must,
774                should,
775                must_not,
776                global_stats.as_ref(),
777                proximity,
778                text_tuning,
779                reader,
780                limit,
781                options,
782                scorer_with_options,
783                get_postings,
784                execute,
785                await
786            )
787        })
788    }
789
790    #[cfg(feature = "sync")]
791    fn scorer_sync<'a>(
792        &self,
793        reader: &'a SegmentReader,
794        limit: usize,
795    ) -> crate::Result<Box<dyn Scorer + 'a>> {
796        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
797    }
798
799    #[cfg(feature = "sync")]
800    fn scorer_sync_with_options<'a>(
801        &self,
802        reader: &'a SegmentReader,
803        limit: usize,
804        options: super::ScorerOptions,
805    ) -> crate::Result<Box<dyn Scorer + 'a>> {
806        let global_stats = self
807            .global_stats
808            .clone()
809            .or_else(|| options.global_stats.clone());
810        boolean_plan!(
811            self.must,
812            self.should,
813            self.must_not,
814            global_stats.as_ref(),
815            self.proximity,
816            (self.text_heap_factor, self.max_terms),
817            reader,
818            limit,
819            options,
820            scorer_sync_with_options,
821            get_postings_sync,
822            execute_sync
823        )
824    }
825
826    fn text_terms(&self, out: &mut Vec<(crate::dsl::Field, Vec<u8>)>) {
827        for clause in self.must.iter().chain(&self.should).chain(&self.must_not) {
828            clause.text_terms(out);
829        }
830    }
831
832    fn decompose(&self) -> super::QueryDecomposition {
833        // LSP/0 selection depends only on the sparse scoring clauses. Pure
834        // filters may remove documents but cannot increase their score, so a
835        // query-global superblock plan remains valid and must be shared across
836        // segments for filtered sparse queries too. A scoring MUST clause can
837        // change final ordering, therefore keep that shape opaque.
838        if self.should.is_empty() || self.must.iter().any(|query| !query.is_filter()) {
839            return super::QueryDecomposition::Opaque;
840        }
841        extract_all_sparse_infos(&self.should)
842            .map(super::QueryDecomposition::SparseTerms)
843            .unwrap_or(super::QueryDecomposition::Opaque)
844    }
845
846    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
847        if self.must.is_empty() && self.should.is_empty() {
848            return None;
849        }
850
851        let num_docs = reader.num_docs();
852
853        // MUST clauses: intersect bitsets (AND)
854        let mut result: Option<super::DocBitset> = None;
855        for q in &self.must {
856            let bs = q.as_doc_bitset(reader)?;
857            match result {
858                None => result = Some(bs),
859                Some(ref mut acc) => acc.intersect_with(&bs),
860            }
861        }
862
863        // SHOULD clauses: union bitsets (OR), then intersect with MUST result
864        if !self.should.is_empty() {
865            let mut should_union = super::DocBitset::new(num_docs);
866            for q in &self.should {
867                let bs = q.as_doc_bitset(reader)?;
868                should_union.union_with(&bs);
869            }
870            match result {
871                None => result = Some(should_union),
872                Some(ref mut acc) => {
873                    // When MUST clauses exist, SHOULD is optional (doesn't filter).
874                    // When no MUST clauses, at least one SHOULD must match.
875                    if self.must.is_empty() {
876                        *acc = should_union;
877                    }
878                }
879            }
880        }
881
882        // MUST_NOT clauses: subtract bitsets (ANDNOT)
883        if let Some(ref mut acc) = result {
884            for q in &self.must_not {
885                {
886                    let bs = q.as_doc_bitset(reader)?;
887                    acc.subtract(&bs);
888                }
889            }
890        }
891
892        result
893    }
894
895    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
896        // Need at least some clauses
897        if self.must.is_empty() && self.should.is_empty() {
898            return None;
899        }
900
901        // Try converting all clauses to predicates; bail if any child can't
902        let must_preds: Vec<_> = self
903            .must
904            .iter()
905            .map(|q| q.as_doc_predicate(reader))
906            .collect::<Option<Vec<_>>>()?;
907        let should_preds: Vec<_> = self
908            .should
909            .iter()
910            .map(|q| q.as_doc_predicate(reader))
911            .collect::<Option<Vec<_>>>()?;
912        let must_not_preds: Vec<_> = self
913            .must_not
914            .iter()
915            .map(|q| q.as_doc_predicate(reader))
916            .collect::<Option<Vec<_>>>()?;
917
918        let has_must = !must_preds.is_empty();
919
920        Some(Box::new(move |doc_id| {
921            // All MUST predicates must pass
922            if !must_preds.iter().all(|p| p(doc_id)) {
923                return false;
924            }
925            // When there are no MUST clauses, at least one SHOULD must pass
926            if !has_must && !should_preds.is_empty() && !should_preds.iter().any(|p| p(doc_id)) {
927                return false;
928            }
929            // No MUST_NOT predicate should pass
930            must_not_preds.iter().all(|p| !p(doc_id))
931        }))
932    }
933
934    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
935        let must = self.must.clone();
936        let should = self.should.clone();
937
938        Box::pin(async move {
939            if !must.is_empty() {
940                let mut estimates = Vec::with_capacity(must.len());
941                for q in &must {
942                    estimates.push(q.count_estimate(reader).await?);
943                }
944                estimates
945                    .into_iter()
946                    .min()
947                    .ok_or_else(|| crate::Error::Corruption("Empty must clause".to_string()))
948            } else if !should.is_empty() {
949                let mut sum = 0u32;
950                for q in &should {
951                    sum = sum.saturating_add(q.count_estimate(reader).await?);
952                }
953                Ok(sum)
954            } else {
955                Ok(0)
956            }
957        })
958    }
959}
960
961struct BooleanScorer<'a> {
962    must: Vec<Box<dyn Scorer + 'a>>,
963    should: Vec<Box<dyn Scorer + 'a>>,
964    must_not: Vec<Box<dyn Scorer + 'a>>,
965    current_doc: DocId,
966}
967
968impl BooleanScorer<'_> {
969    fn find_next_match(&mut self) -> DocId {
970        if self.must.is_empty() && self.should.is_empty() {
971            return TERMINATED;
972        }
973
974        loop {
975            let candidate = if !self.must.is_empty() {
976                let mut max_doc = self
977                    .must
978                    .iter()
979                    .map(|s| s.doc())
980                    .max()
981                    .unwrap_or(TERMINATED);
982
983                if max_doc == TERMINATED {
984                    return TERMINATED;
985                }
986
987                loop {
988                    let mut all_match = true;
989                    for scorer in &mut self.must {
990                        let doc = scorer.seek(max_doc);
991                        if doc == TERMINATED {
992                            return TERMINATED;
993                        }
994                        if doc > max_doc {
995                            max_doc = doc;
996                            all_match = false;
997                            break;
998                        }
999                    }
1000                    if all_match {
1001                        break;
1002                    }
1003                }
1004                max_doc
1005            } else {
1006                self.should
1007                    .iter()
1008                    .map(|s| s.doc())
1009                    .filter(|&d| d != TERMINATED)
1010                    .min()
1011                    .unwrap_or(TERMINATED)
1012            };
1013
1014            if candidate == TERMINATED {
1015                return TERMINATED;
1016            }
1017
1018            let excluded = self.must_not.iter_mut().any(|scorer| {
1019                let doc = scorer.seek(candidate);
1020                doc == candidate
1021            });
1022
1023            if !excluded {
1024                // Seek SHOULD scorers to candidate so score() can see their contributions
1025                for scorer in &mut self.should {
1026                    scorer.seek(candidate);
1027                }
1028                self.current_doc = candidate;
1029                return candidate;
1030            }
1031
1032            // Advance past excluded candidate
1033            if !self.must.is_empty() {
1034                for scorer in &mut self.must {
1035                    scorer.advance();
1036                }
1037            } else {
1038                // For SHOULD-only: seek all scorers past the excluded candidate
1039                for scorer in &mut self.should {
1040                    if scorer.doc() <= candidate && scorer.doc() != TERMINATED {
1041                        scorer.seek(candidate + 1);
1042                    }
1043                }
1044            }
1045        }
1046    }
1047}
1048
1049impl super::docset::DocSet for BooleanScorer<'_> {
1050    fn doc(&self) -> DocId {
1051        self.current_doc
1052    }
1053
1054    fn advance(&mut self) -> DocId {
1055        if !self.must.is_empty() {
1056            for scorer in &mut self.must {
1057                scorer.advance();
1058            }
1059        } else {
1060            for scorer in &mut self.should {
1061                if scorer.doc() == self.current_doc {
1062                    scorer.advance();
1063                }
1064            }
1065        }
1066
1067        self.current_doc = self.find_next_match();
1068        self.current_doc
1069    }
1070
1071    fn seek(&mut self, target: DocId) -> DocId {
1072        for scorer in &mut self.must {
1073            scorer.seek(target);
1074        }
1075
1076        for scorer in &mut self.should {
1077            scorer.seek(target);
1078        }
1079
1080        self.current_doc = self.find_next_match();
1081        self.current_doc
1082    }
1083
1084    fn size_hint(&self) -> u32 {
1085        if !self.must.is_empty() {
1086            self.must.iter().map(|s| s.size_hint()).min().unwrap_or(0)
1087        } else {
1088            self.should.iter().map(|s| s.size_hint()).sum()
1089        }
1090    }
1091}
1092
1093impl Scorer for BooleanScorer<'_> {
1094    fn score(&self) -> Score {
1095        let mut total = 0.0;
1096
1097        for scorer in &self.must {
1098            if scorer.doc() == self.current_doc {
1099                total += scorer.score();
1100            }
1101        }
1102
1103        for scorer in &self.should {
1104            if scorer.doc() == self.current_doc {
1105                total += scorer.score();
1106            }
1107        }
1108
1109        total
1110    }
1111
1112    fn matched_positions(&self) -> Option<super::MatchedPositions> {
1113        let mut all_positions: super::MatchedPositions = Vec::new();
1114
1115        for scorer in &self.must {
1116            if scorer.doc() == self.current_doc
1117                && let Some(positions) = scorer.matched_positions()
1118            {
1119                all_positions.extend(positions);
1120            }
1121        }
1122
1123        for scorer in &self.should {
1124            if scorer.doc() == self.current_doc
1125                && let Some(positions) = scorer.matched_positions()
1126            {
1127                all_positions.extend(positions);
1128            }
1129        }
1130
1131        if all_positions.is_empty() {
1132            None
1133        } else {
1134            Some(merge_matched_positions(all_positions))
1135        }
1136    }
1137}
1138
1139/// Coalesce the position lists that several clauses reported for one field.
1140///
1141/// Two term clauses on the same chunked field each report the chunk ordinal
1142/// they matched; the union must present one entry per chunk whose score is
1143/// the sum of the clause contributions (the chunk's BM25 score), not the same
1144/// ordinal twice. Distinct positions are left untouched, so token positions of
1145/// `positions`-mode fields keep their per-term scores.
1146pub(super) fn merge_matched_positions(
1147    positions: super::MatchedPositions,
1148) -> super::MatchedPositions {
1149    if positions.len() < 2 {
1150        return positions;
1151    }
1152    let mut merged: super::MatchedPositions = Vec::with_capacity(positions.len());
1153    for (field_id, scored) in positions {
1154        match merged
1155            .iter_mut()
1156            .find(|(existing, _)| *existing == field_id)
1157        {
1158            Some((_, existing)) => existing.extend(scored),
1159            None => merged.push((field_id, scored)),
1160        }
1161    }
1162    for (_, scored) in &mut merged {
1163        if scored.len() < 2 {
1164            continue;
1165        }
1166        scored.sort_by_key(|sp| sp.position);
1167        let mut write = 0usize;
1168        for read in 1..scored.len() {
1169            if scored[read].position == scored[write].position {
1170                scored[write].score += scored[read].score;
1171            } else {
1172                write += 1;
1173                scored[write] = scored[read];
1174            }
1175        }
1176        scored.truncate(write + 1);
1177    }
1178    merged
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184    use crate::dsl::Field;
1185    use crate::query::{QueryDecomposition, TermQuery};
1186
1187    #[test]
1188    fn test_maxscore_eligible_pure_or_same_field() {
1189        // Pure OR query with multiple terms in same field should be MaxScore-eligible
1190        let query = BooleanQuery::new()
1191            .should(TermQuery::text(Field(0), "hello"))
1192            .should(TermQuery::text(Field(0), "world"))
1193            .should(TermQuery::text(Field(0), "foo"));
1194
1195        // All clauses should return term info
1196        assert!(
1197            query
1198                .should
1199                .iter()
1200                .all(|q| matches!(q.decompose(), QueryDecomposition::TextTerm(_)))
1201        );
1202
1203        // All should be same field
1204        let infos: Vec<_> = query
1205            .should
1206            .iter()
1207            .filter_map(|q| match q.decompose() {
1208                QueryDecomposition::TextTerm(info) => Some(info),
1209                _ => None,
1210            })
1211            .collect();
1212        assert_eq!(infos.len(), 3);
1213        assert!(infos.iter().all(|i| i.field == Field(0)));
1214    }
1215
1216    #[test]
1217    fn test_maxscore_not_eligible_different_fields() {
1218        // OR query with terms in different fields should NOT use MaxScore
1219        let query = BooleanQuery::new()
1220            .should(TermQuery::text(Field(0), "hello"))
1221            .should(TermQuery::text(Field(1), "world")); // Different field!
1222
1223        let infos: Vec<_> = query
1224            .should
1225            .iter()
1226            .filter_map(|q| match q.decompose() {
1227                QueryDecomposition::TextTerm(info) => Some(info),
1228                _ => None,
1229            })
1230            .collect();
1231        assert_eq!(infos.len(), 2);
1232        // Fields are different, MaxScore should not be used
1233        assert!(infos[0].field != infos[1].field);
1234    }
1235
1236    #[test]
1237    fn test_maxscore_not_eligible_with_must() {
1238        // Query with MUST clause should NOT use MaxScore optimization
1239        let query = BooleanQuery::new()
1240            .must(TermQuery::text(Field(0), "required"))
1241            .should(TermQuery::text(Field(0), "hello"))
1242            .should(TermQuery::text(Field(0), "world"));
1243
1244        // Has MUST clause, so MaxScore optimization should not kick in
1245        assert!(!query.must.is_empty());
1246    }
1247
1248    #[test]
1249    fn test_maxscore_not_eligible_with_must_not() {
1250        // Query with MUST_NOT clause should NOT use MaxScore optimization
1251        let query = BooleanQuery::new()
1252            .should(TermQuery::text(Field(0), "hello"))
1253            .should(TermQuery::text(Field(0), "world"))
1254            .must_not(TermQuery::text(Field(0), "excluded"));
1255
1256        // Has MUST_NOT clause, so MaxScore optimization should not kick in
1257        assert!(!query.must_not.is_empty());
1258    }
1259
1260    #[test]
1261    fn test_maxscore_not_eligible_single_term() {
1262        // Single SHOULD clause should NOT use MaxScore (no benefit)
1263        let query = BooleanQuery::new().should(TermQuery::text(Field(0), "hello"));
1264
1265        // Only one term, MaxScore not beneficial
1266        assert_eq!(query.should.len(), 1);
1267    }
1268
1269    #[test]
1270    fn test_term_query_info_extraction() {
1271        let term_query = TermQuery::text(Field(42), "test");
1272        match term_query.decompose() {
1273            QueryDecomposition::TextTerm(info) => {
1274                assert_eq!(info.field, Field(42));
1275                assert_eq!(info.term, b"test");
1276            }
1277            _ => panic!("Expected TextTerm decomposition"),
1278        }
1279    }
1280
1281    #[test]
1282    fn test_boolean_query_no_term_info() {
1283        // BooleanQuery itself should not return term info
1284        let query = BooleanQuery::new().should(TermQuery::text(Field(0), "hello"));
1285
1286        assert!(matches!(query.decompose(), QueryDecomposition::Opaque));
1287    }
1288}