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