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 divided by `heap_factor`
32    /// (< 1 prunes beyond rank safety, like sparse). 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 / `heap_factor`), like sparse.
146    /// 1 is exact; [0, 1) prunes more aggressively, with an effective 0.01
147    /// floor. Non-finite values and values outside [0, 1] fail construction
148    /// of the scorer. RPC zero/unset is normalized to 1 by the adapter.
149    pub fn with_text_heap_factor(mut self, heap_factor: f32) -> Self {
150        self.text_heap_factor = heap_factor;
151        self
152    }
153
154    /// Cap the text terms scored per field group to the `max_terms` rarest
155    /// (highest idf) ones; 0 = no cap.
156    pub fn with_max_terms(mut self, max_terms: usize) -> Self {
157        self.max_terms = max_terms;
158        self
159    }
160}
161
162/// Flatten nested pure-SHOULD Boolean queries into one SHOULD list.
163///
164/// `OR(OR(a, b), c)` scores exactly like `OR(a, b, c)`, and only the flat
165/// form reaches MaxScore and filter push-down. The nested form would be an
166/// opaque sub-scorer whose top-k truncation can hide matches from the outer
167/// query.
168fn flatten_should(should: &[Arc<dyn Query>]) -> std::borrow::Cow<'_, [Arc<dyn Query>]> {
169    if !should.iter().any(|query| query.should_children().is_some()) {
170        return std::borrow::Cow::Borrowed(should);
171    }
172
173    fn push_flat(out: &mut Vec<Arc<dyn Query>>, query: &Arc<dyn Query>) {
174        match query.should_children() {
175            Some(children) => children.iter().for_each(|child| push_flat(out, child)),
176            None => out.push(Arc::clone(query)),
177        }
178    }
179
180    let mut flat = Vec::with_capacity(should.len());
181    should.iter().for_each(|query| push_flat(&mut flat, query));
182    std::borrow::Cow::Owned(flat)
183}
184
185/// Build a SHOULD-only scorer from a vec of optimized scorers.
186fn build_should_scorer<'a>(scorers: Vec<Box<dyn Scorer + 'a>>) -> Box<dyn Scorer + 'a> {
187    if scorers.is_empty() {
188        return Box::new(EmptyScorer);
189    }
190    if scorers.len() == 1 {
191        return scorers.into_iter().next().unwrap();
192    }
193    let mut scorer = BooleanScorer {
194        must: vec![],
195        should: scorers,
196        must_not: vec![],
197        current_doc: 0,
198    };
199    scorer.current_doc = scorer.find_next_match();
200    Box::new(scorer)
201}
202
203// ── Planner macro ────────────────────────────────────────────────────────
204//
205// Unified planner for both async and sync paths.  Parameterised on:
206//   $scorer_fn      – scorer_with_options | scorer_sync_with_options
207//   $get_postings_fn – get_postings | get_postings_sync
208//   $execute_fn     – execute | execute_sync
209//   $($aw)*         – .await  (present for async, absent for sync)
210//
211// Decision order:
212//   1. Single-clause unwrap
213//   2. Pure OR → text MaxScore | sparse MaxScore | per-field MaxScore
214//   3. Filter push-down → predicate-aware sparse MaxScore | PredicatedScorer
215//   4. Standard BooleanScorer fallback
216macro_rules! boolean_plan {
217    ($must:expr, $should:expr, $must_not:expr, $global_stats:expr, $proximity:expr, $text_tuning:expr,
218     $reader:expr, $limit:expr, $scorer_options:expr,
219     $scorer_fn:ident, $get_postings_fn:ident, $execute_fn:ident
220     $(, $aw:tt)*) => {{
221        let must: &[Arc<dyn Query>] = &$must;
222        let should_flat = flatten_should(&$should);
223        let should_all: &[Arc<dyn Query>] = &should_flat;
224        let must_not: &[Arc<dyn Query>] = &$must_not;
225        let global_stats: Option<&Arc<GlobalStats>> = $global_stats;
226        let reader: &SegmentReader = $reader;
227        let limit: usize = $limit;
228        let scorer_options: super::ScorerOptions = $scorer_options;
229        if !$text_tuning.0.is_finite() || !(0.0..=1.0).contains(&$text_tuning.0) {
230            return Err(crate::Error::Query(
231                "Text heap_factor must be finite and between 0 and 1".into(),
232            ));
233        }
234        if scorer_options.stop_if_expired() {
235            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
236        }
237
238        // Cap SHOULD clauses to MAX_QUERY_TERMS, but only count queries that need
239        // posting-list cursors. Fast-field predicates (O(1) per doc) are exempt.
240        let should_capped: Vec<Arc<dyn Query>>;
241        let should: &[Arc<dyn Query>] = if should_all.len() > super::MAX_QUERY_TERMS {
242            let is_predicate: Vec<bool> = should_all
243                .iter()
244                .map(|q| q.is_filter() || q.as_doc_predicate(reader).is_some())
245                .collect();
246            let cursor_count = is_predicate.iter().filter(|&&p| !p).count();
247
248            if cursor_count > super::MAX_QUERY_TERMS {
249                let mut kept = Vec::with_capacity(should_all.len());
250                let mut cursor_kept = 0usize;
251                for (q, &is_pred) in should_all.iter().zip(is_predicate.iter()) {
252                    if is_pred {
253                        kept.push(q.clone());
254                    } else if cursor_kept < super::MAX_QUERY_TERMS {
255                        kept.push(q.clone());
256                        cursor_kept += 1;
257                    }
258                }
259                log::debug!(
260                    "BooleanQuery: capping cursor SHOULD from {} to {} ({} fast-field predicates exempt)",
261                    cursor_count,
262                    super::MAX_QUERY_TERMS,
263                    kept.len() - cursor_kept,
264                );
265                should_capped = kept;
266                &should_capped
267            } else {
268                log::debug!(
269                    "BooleanQuery: {} SHOULD clauses OK ({} need cursors, {} fast-field predicates)",
270                    should_all.len(),
271                    cursor_count,
272                    should_all.len() - cursor_count,
273                );
274                should_all
275            }
276        } else {
277            should_all
278        };
279
280        // ── 1. Single-clause optimisation ────────────────────────────────
281        if must_not.is_empty() {
282            if must.len() == 1 && should.is_empty() {
283                return must[0].$scorer_fn(reader, limit, scorer_options) $(.  $aw)* ;
284            }
285            if should.len() == 1 && must.is_empty() && $text_tuning.0 == 1.0 {
286                return should[0].$scorer_fn(reader, limit, scorer_options) $(. $aw)* ;
287            }
288        }
289
290        // ── 2. Pure OR → MaxScore optimisations ──────────────────────────
291        if must.is_empty() && must_not.is_empty()
292            && (should.len() >= 2 || (should.len() == 1 && $text_tuning.0 < 1.0)) {
293            // 2a. Text MaxScore (single-field, all term queries)
294            if let Some((mut infos, text_field, avg_field_len, num_docs)) =
295                prepare_text_maxscore(should, reader, global_stats)
296                && text_maxscore_allowed(reader, text_field, scorer_options.collect_positions)
297            {
298                let mut posting_lists = Vec::with_capacity(infos.len());
299                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
300                for info in infos.drain(..) {
301                    if let Some(pl) = reader.$get_postings_fn(info.field, &info.term)
302                        $(. $aw)* ?
303                    {
304                        let idf = compute_idf(&pl, info.field, &info.term, num_docs, global_stats) * info.weight;
305                        posting_lists.push((pl, idf));
306                        term_bytes.push(info.term.clone());
307                    }
308                }
309                cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
310                // Chunked field: score chunks, fold to documents with ordinals.
311                if reader.is_chunked_field(text_field) {
312                    return finish_chunked_text_maxscore(
313                        posting_lists, limit, reader, text_field, None,
314                        $proximity.map(|config| (config, term_bytes)),
315                        $text_tuning.0,
316                        scorer_options.shared_threshold.as_ref(),
317                    );
318                }
319                // Seed from the cross-segment floor: this path scores final
320                // per-doc BM25 into a top-`limit` heap, so a floor carried from
321                // an already-searched segment prunes exactly (see
322                // SharedThreshold). The per-field path below stays at 0.0 —
323                // its per-field partial scores are not the final doc score.
324                let shared_threshold = std::cell::Cell::new(scorer_options.initial_threshold);
325                return finish_text_maxscore(
326                    posting_lists,
327                    avg_field_len,
328                    reader.doc_lengths(text_field),
329                    limit,
330                    &shared_threshold,
331                    reader,
332                    text_field,
333                    None,
334                    super::Bm25Params::for_field(reader.schema(), text_field),
335                    $proximity.map(|config| (config, term_bytes)),
336                    $text_tuning.0,
337                    scorer_options.shared_threshold.as_ref(),
338                );
339            }
340
341            // 2b. Sparse (single-field, all sparse term queries)
342            // Auto-detect: BMP executor if field has BMP index, else MaxScore
343            if let Some(infos) =
344                shared_or_extract_sparse_infos(scorer_options.lsp_plan.as_ref(), should)
345            {
346                if let Some((raw, info)) =
347                    build_sparse_bmp_results(&infos, reader, limit, &scorer_options)?
348                {
349                    return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
350                }
351                if let Some((executor, info)) =
352                    build_sparse_maxscore_executor(&infos, reader, limit, None)
353                {
354                    let raw = executor.$execute_fn() $(. $aw)* ?;
355                    return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
356                }
357            }
358
359            // 2c. Per-field text MaxScore (multi-field term grouping)
360            if let Some(grouping) = prepare_per_field_grouping(
361                should,
362                reader,
363                limit,
364                global_stats,
365                scorer_options.collect_positions,
366            ) {
367                let mut scorers: Vec<Box<dyn Scorer + '_>> = Vec::new();
368                // Query-local cross-group threshold seeding (see finish_text_maxscore)
369                let shared_threshold = std::cell::Cell::new(0.0f32);
370                for (field, avg_field_len, infos) in &grouping.multi_term_groups {
371                    // Chunked fields: IDF over chunks, not documents.
372                    let corpus_size = reader.text_corpus_size(*field);
373                    let mut posting_lists = Vec::with_capacity(infos.len());
374                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
375                    for info in infos {
376                        if let Some(pl) = reader.$get_postings_fn(info.field, &info.term)
377                            $(. $aw)* ?
378                        {
379                            let idf = compute_idf(
380                                &pl, *field, &info.term, corpus_size, global_stats,
381                            ) * info.weight;
382                            posting_lists.push((pl, idf));
383                        term_bytes.push(info.term.clone());
384                        }
385                    }
386                    cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
387                    if reader.is_chunked_field(*field) {
388                        scorers.push(finish_chunked_text_maxscore(
389                            posting_lists,
390                            grouping.per_field_limit,
391                            reader,
392                            *field,
393                            None,
394                            $proximity.map(|config| (config, term_bytes)),
395                            $text_tuning.0,
396                            scorer_options.shared_threshold.as_ref(),
397                        )?);
398                    } else if !posting_lists.is_empty() {
399                        scorers.push(finish_text_maxscore(
400                            posting_lists,
401                            *avg_field_len,
402                            reader.doc_lengths(*field),
403                            grouping.per_field_limit,
404                            &shared_threshold,
405                            reader,
406                            *field,
407                            None,
408                            super::Bm25Params::for_field(reader.schema(), *field),
409                            $proximity.map(|config| (config, term_bytes)),
410                            $text_tuning.0,
411                            scorer_options.shared_threshold.as_ref(),
412                        )?);
413                    }
414                }
415                for &idx in &grouping.fallback_indices {
416                    scorers.push(should[idx].$scorer_fn(
417                        reader,
418                        limit,
419                        scorer_options.without_threshold(),
420                    ) $(. $aw)* ?);
421                }
422                return Ok(build_should_scorer(scorers));
423            }
424        }
425
426        // ── 3. Filter push-down (MUST + SHOULD) ─────────────────────────
427        //
428        // Position collection no longer disables this path: fast-field
429        // predicates carry no positions to lose and verifier scorers keep
430        // theirs. Only the posting-list bitset shortcut is skipped when
431        // positions are requested, because a bitset cannot report them.
432        if !should.is_empty() && (!must.is_empty() || !must_not.is_empty()) {
433            // ── 3-text. Text SHOULD with materializable filters ──────────
434            //
435            // When every SHOULD clause is a text term and the MUST/MUST_NOT
436            // clauses combine into one document bitset (term filters, ranges,
437            // quoted phrases via `PhraseQuery::as_doc_bitset`), the text
438            // MaxScore executors run with the bitset as a predicate: the
439            // top-k is exact over the filtered documents (bounds are unaffected
440            // by a filter), instead of an over-fetched unfiltered top-k that a
441            // PredicatedScorer thins out afterwards. Documents matching only
442            // the filters (score 0) fill the tail when fewer than `limit`
443            // scored documents survive, keeping Boolean semantics.
444            let text_groups: Option<Vec<(crate::Field, Vec<super::TermQueryInfo>)>> = {
445                let mut groups: Vec<(crate::Field, Vec<super::TermQueryInfo>)> = Vec::new();
446                let mut all_text = true;
447                for q in should {
448                    match q.decompose() {
449                        super::QueryDecomposition::TextTerm(info)
450                            if text_maxscore_allowed(
451                                reader, info.field, scorer_options.collect_positions,
452                            ) =>
453                        {
454                            match groups.iter_mut().find(|(f, _)| *f == info.field) {
455                                Some((_, infos)) => infos.push(info),
456                                None => groups.push((info.field, vec![info])),
457                            }
458                        }
459                        _ => {
460                            all_text = false;
461                            break;
462                        }
463                    }
464                }
465                all_text.then_some(groups)
466            };
467            if must.iter().all(|query| {
468                query.is_filter()
469                    || query.as_doc_predicate(reader).is_some()
470                    || (!matches!(
471                        query.decompose(),
472                        super::QueryDecomposition::TextTerm(_)
473                    ) && scorer_options.doc_bitset(query.as_ref(), reader).is_some())
474            })
475                && let Some(groups) = text_groups
476                && (groups.len() == 1
477                    || ($proximity.is_none()
478                        && groups
479                            .iter()
480                            .all(|(field, _)| !reader.is_chunked_field(*field))))
481                && let Some(bitset) = build_combined_bitset(must, must_not, reader, &scorer_options)
482            {
483                if scorer_options.stop_if_expired() {
484                    return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
485                }
486                let bitset = std::sync::Arc::new(bitset);
487                let single_field = groups.len() == 1;
488
489                // Scores from different fields are additive. Running a
490                // separate top-k per field and merging those windows is not
491                // exact: a document just below every local cutoff can still
492                // win after its field scores are summed. Non-chunked text
493                // fields share document ids, so put all of their cursors in
494                // one executor and apply the filter there.
495                if !single_field {
496                    let mut cursors = Vec::new();
497                    for (field, infos) in groups {
498                        let corpus_size = reader.text_corpus_size(field);
499                        let avg_field_len = global_stats
500                            .map(|stats| stats.avg_field_len(field))
501                            .unwrap_or_else(|| reader.avg_field_len(field));
502                        let params = super::Bm25Params::for_field(reader.schema(), field);
503                        let mut posting_lists = Vec::with_capacity(infos.len());
504                        let mut term_bytes = Vec::with_capacity(infos.len());
505                        for info in &infos {
506                            if let Some(postings) =
507                                reader.$get_postings_fn(field, &info.term) $(. $aw)* ?
508                            {
509                                let idf = compute_idf(
510                                    &postings,
511                                    field,
512                                    &info.term,
513                                    corpus_size,
514                                    global_stats,
515                                ) * info.weight;
516                                posting_lists.push((postings, idf));
517                                term_bytes.push(info.term.clone());
518                            }
519                        }
520                        cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
521                        cursors.extend(posting_lists.into_iter().map(|(postings, idf)| {
522                            super::TermCursor::text_with_params(
523                                postings,
524                                idf,
525                                avg_field_len,
526                                reader.doc_lengths(field).map(super::LengthSource::Docs),
527                                params,
528                            )
529                        }));
530                    }
531
532                    let filter = bitset.clone();
533                    let predicate: super::DocPredicate<'_> =
534                        Box::new(move |doc_id| filter.contains(doc_id));
535                    let mut executor = super::MaxScoreExecutor::new(
536                        cursors,
537                        limit,
538                        $text_tuning.0,
539                    )
540                    .with_metric_labels(reader.schema().index_label(), "<multiple>")
541                    .with_predicate(predicate)
542                    .with_budget(scorer_options.shared_threshold.clone());
543                    if $text_tuning.0 == 1.0 && scorer_options.initial_threshold > 0.0 {
544                        executor.seed_threshold(scorer_options.initial_threshold);
545                    }
546                    let results = executor.execute_sync()?;
547                    let found = results.len() as u32;
548                    let should_scorer: Box<dyn Scorer + '_> =
549                        Box::new(super::planner::TopKResultScorer::new(results));
550                    if !must.is_empty() && (found as usize) < limit && bitset.count() > found {
551                        return Ok(Box::new(super::planner::BitsetFillScorer::new(
552                            should_scorer,
553                            bitset,
554                        )));
555                    }
556                    return Ok(should_scorer);
557                }
558
559                let group_limit = if single_field {
560                    limit
561                } else {
562                    super::max_candidate_limit(limit)
563                        .min(reader.num_docs() as usize)
564                        .max(1)
565                };
566                // Cross-segment floor only when the group score is the final
567                // document score (single field); per-field partial scores
568                // start at 0.0 like path 2c.
569                let shared_threshold = std::cell::Cell::new(if single_field {
570                    scorer_options.initial_threshold
571                } else {
572                    0.0
573                });
574                let mut scorers: Vec<Box<dyn Scorer + '_>> = Vec::new();
575                let mut found = 0u32;
576                let mut complete = true;
577                for (field, infos) in groups {
578                    let corpus_size = reader.text_corpus_size(field);
579                    let avg_field_len = global_stats
580                        .map(|s| s.avg_field_len(field))
581                        .unwrap_or_else(|| reader.avg_field_len(field));
582                    let mut posting_lists = Vec::with_capacity(infos.len());
583                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
584                    for info in &infos {
585                        if let Some(pl) = reader.$get_postings_fn(field, &info.term) $(. $aw)* ? {
586                            let idf = compute_idf(&pl, field, &info.term, corpus_size, global_stats) * info.weight;
587                            posting_lists.push((pl, idf));
588                        term_bytes.push(info.term.clone());
589                        }
590                    }
591                    cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
592                    let filter = bitset.clone();
593                    let predicate: super::DocPredicate<'_> =
594                        Box::new(move |doc_id| filter.contains(doc_id));
595                    let scorer = if reader.is_chunked_field(field) {
596                        finish_chunked_text_maxscore(
597                            posting_lists, group_limit, reader, field, Some(predicate),
598                            $proximity.map(|config| (config, term_bytes)),
599                            $text_tuning.0,
600                            scorer_options.shared_threshold.as_ref(),
601                        )?
602                    } else {
603                        finish_text_maxscore(
604                            posting_lists,
605                            avg_field_len,
606                            reader.doc_lengths(field),
607                            group_limit,
608                            &shared_threshold,
609                            reader,
610                            field,
611                            Some(predicate),
612                            super::Bm25Params::for_field(reader.schema(), field),
613                            $proximity.map(|config| (config, term_bytes)),
614                            $text_tuning.0,
615                            scorer_options.shared_threshold.as_ref(),
616                        )?
617                    };
618                    let hits = scorer.size_hint();
619                    found = found.saturating_add(hits);
620                    if hits as usize >= group_limit {
621                        complete = false;
622                    }
623                    scorers.push(scorer);
624                }
625                log::debug!(
626                    "BooleanQuery planner: bitset-aware text MaxScore, {} field group(s), \
627                     {} filtered docs, {} scored hits",
628                    scorers.len(),
629                    bitset.count(),
630                    found
631                );
632                let should_scorer = build_should_scorer(scorers);
633                if !must.is_empty()
634                    && complete
635                    && (found as usize) < limit
636                    && bitset.count() > found
637                {
638                    return Ok(Box::new(super::planner::BitsetFillScorer::new(
639                        should_scorer,
640                        bitset,
641                    )));
642                }
643                return Ok(should_scorer);
644            }
645
646            // Pre-check: is SHOULD all-sparse? This determines whether we can
647            // use bitset fallback for MUST clauses that lack fast-field predicates.
648            // For sparse SHOULD, the predicate is pushed into BMP/MaxScore traversal
649            // so all qualifying docs are found. For text SHOULD, we must NOT convert
650            // MUST to a predicate (PredicatedScorer would drop MUST-only docs that
651            // don't match SHOULD), so those go to verifier → BooleanScorer.
652            let should_is_sparse = scorer_options.lsp_plan.is_some()
653                || extract_all_sparse_infos(should).is_some();
654            let bitset_predicates_allowed = should_is_sparse && !scorer_options.collect_positions;
655
656            // 3a. Compile MUST → predicates (O(1)) vs verifier scorers (seek)
657            //
658            // Priority: as_doc_predicate (fast-field O(1)) > as_doc_bitset
659            // (posting-list materialization, O(1) lookup, sparse-SHOULD only)
660            // > verifier scorer (seek).
661            let mut predicates: Vec<super::DocPredicate<'_>> = Vec::new();
662            let mut must_verifiers: Vec<Box<dyn super::Scorer + '_>> = Vec::new();
663            for q in must {
664                if let Some(pred) = q.as_doc_predicate(reader) {
665                    log::debug!("BooleanQuery planner 3a: MUST clause → predicate ({})", q);
666                    predicates.push(pred);
667                } else if bitset_predicates_allowed {
668                    if let Some(bitset) = scorer_options.doc_bitset(q.as_ref(), reader) {
669                        log::debug!("BooleanQuery planner 3a: MUST clause → bitset predicate ({})", q);
670                        predicates.push(Box::new(move |doc_id| bitset.contains(doc_id)));
671                    } else {
672                        log::debug!("BooleanQuery planner 3a: MUST clause → verifier scorer ({})", q);
673                        must_verifiers.push(q.$scorer_fn(
674                            reader, limit, scorer_options.without_threshold()
675                        ) $(. $aw)* ?);
676                    }
677                } else {
678                    log::debug!("BooleanQuery planner 3a: MUST clause → verifier scorer ({})", q);
679                    must_verifiers.push(q.$scorer_fn(
680                        reader, limit, scorer_options.without_threshold()
681                    ) $(. $aw)* ?);
682                }
683            }
684            // Compile MUST_NOT → negated predicates vs verifier scorers
685            let mut must_not_verifiers: Vec<Box<dyn super::Scorer + '_>> = Vec::new();
686            for q in must_not {
687                if let Some(pred) = q.as_doc_predicate(reader) {
688                    let negated: super::DocPredicate<'_> =
689                        Box::new(move |doc_id| !pred(doc_id));
690                    predicates.push(negated);
691                } else if bitset_predicates_allowed {
692                    if let Some(bitset) = scorer_options.doc_bitset(q.as_ref(), reader) {
693                        log::debug!("BooleanQuery planner 3a: MUST_NOT clause → bitset predicate ({})", q);
694                        predicates.push(Box::new(move |doc_id| !bitset.contains(doc_id)));
695                    } else {
696                        must_not_verifiers.push(q.$scorer_fn(
697                            reader, limit, scorer_options.without_threshold()
698                        ) $(. $aw)* ?);
699                    }
700                } else {
701                    must_not_verifiers.push(q.$scorer_fn(
702                        reader, limit, scorer_options.without_threshold()
703                    ) $(. $aw)* ?);
704                }
705            }
706
707            // 3b. Fast path: pure predicates + sparse SHOULD → BMP or MaxScore w/ predicate
708            if scorer_options.stop_if_expired() {
709                return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
710            }
711            if must_verifiers.is_empty()
712                && must_not_verifiers.is_empty()
713                && !predicates.is_empty()
714            {
715                let sparse_infos =
716                    shared_or_extract_sparse_infos(scorer_options.lsp_plan.as_ref(), should);
717                if let Some(infos) = sparse_infos {
718                    // Try BMP with bitset first: build compact bitset from MUST/MUST_NOT
719                    // posting lists (O(M) for term queries) for fast per-slot lookup.
720                    let bitset_result = build_combined_bitset(must, must_not, reader, &scorer_options);
721                    if scorer_options.stop_if_expired() {
722                        return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
723                    }
724                    if let Some(ref bitset) = bitset_result {
725                        let bitset_pred = |doc_id: crate::DocId| bitset.contains(doc_id);
726                        if let Some((raw, info)) =
727                            build_sparse_bmp_results_filtered(
728                                &infos, reader, limit, &bitset_pred, &scorer_options
729                            )?
730                        {
731                            log::debug!(
732                                "BooleanQuery planner: bitset-aware sparse BMP, {} dims, {} matching docs",
733                                infos.len(),
734                                bitset.count()
735                            );
736                            return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
737                        }
738                    }
739
740                    // Fallback: closure predicate (for queries that don't support bitsets)
741                    let combined = chain_predicates(predicates);
742                    if let Some((raw, info)) =
743                        build_sparse_bmp_results_filtered(
744                            &infos, reader, limit, &*combined, &scorer_options
745                        )?
746                    {
747                        log::debug!(
748                            "BooleanQuery planner: predicate-aware sparse BMP, {} dims",
749                            infos.len()
750                        );
751                        return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
752                    }
753                    // Try MaxScore with predicate
754                    if let Some((executor, info)) =
755                        build_sparse_maxscore_executor(&infos, reader, limit, Some(combined))
756                    {
757                        log::debug!(
758                            "BooleanQuery planner: predicate-aware sparse MaxScore, {} dims",
759                            infos.len()
760                        );
761                        let raw = executor.$execute_fn() $(. $aw)* ?;
762                        return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
763                    }
764                    // predicates consumed — cannot fall through; rebuild them
765                    // (this path only triggers if neither sparse index exists)
766                    // should_is_sparse is true here (we're inside extract_all_sparse_infos)
767                    predicates = Vec::new();
768                    for q in must {
769                        if let Some(pred) = q.as_doc_predicate(reader) {
770                            predicates.push(pred);
771                        } else if let Some(bitset) = scorer_options.doc_bitset(q.as_ref(), reader) {
772                            predicates.push(Box::new(move |doc_id| bitset.contains(doc_id)));
773                        }
774                    }
775                    for q in must_not {
776                        if let Some(pred) = q.as_doc_predicate(reader) {
777                            let negated: super::DocPredicate<'_> =
778                                Box::new(move |doc_id| !pred(doc_id));
779                            predicates.push(negated);
780                        } else if let Some(bitset) = scorer_options.doc_bitset(q.as_ref(), reader) {
781                            predicates.push(Box::new(move |doc_id| !bitset.contains(doc_id)));
782                        }
783                    }
784                }
785            }
786
787            // 3c. Generic fallback — never filter a truncated SHOULD window.
788            // Sparse retrieval keeps its combined candidate executor. Other
789            // query shapes use the individual SHOULD streams so filters and
790            // scoring requirements see the complete document streams.
791            let mut should_options = scorer_options.without_threshold();
792            if should_is_sparse {
793                // The outer decomposition built this plan from the complete
794                // sparse SHOULD expression. Filters cannot increase scores,
795                // so retain global γ even when a verifier prevents predicate
796                // push-down. Thresholds still belong to the outer score space
797                // and remain cleared.
798                should_options.lsp_plan = scorer_options.lsp_plan.clone();
799            }
800            let proximity_should = $proximity.is_some();
801            let combined_should = should.len() == 1 || should_is_sparse || proximity_should;
802            let should_scorer: Option<Box<dyn Scorer + '_>> = if should.len() == 1 {
803                Some(should[0].$scorer_fn(reader, limit, should_options.clone()) $(. $aw)* ?)
804            } else if should_is_sparse || proximity_should {
805                let sub = BooleanQuery {
806                    must: Vec::new(),
807                    should: should.to_vec(),
808                    must_not: Vec::new(),
809                    global_stats: global_stats.cloned(),
810                    proximity: $proximity,
811                    text_heap_factor: $text_tuning.0,
812                    max_terms: $text_tuning.1,
813                };
814                // Proximity is a positive second-stage bonus. Preserve the
815                // complete SHOULD stream before applying outer requirements;
816                // a bounded BM25-only window can omit the document whose
817                // proximity bonus would promote it. Chunked fields use their
818                // virtual-id corpus size, plain fields their document count.
819                let sub_limit = if proximity_should {
820                    should
821                        .first()
822                        .and_then(|query| match query.decompose() {
823                            super::QueryDecomposition::TextTerm(info) => {
824                                Some(reader.text_corpus_size(info.field) as usize)
825                            }
826                            _ => None,
827                        })
828                        .unwrap_or(reader.num_docs() as usize)
829                        .max(limit)
830                } else {
831                    super::max_candidate_limit(limit)
832                };
833                Some(sub.$scorer_fn(
834                    reader,
835                    sub_limit,
836                    should_options.clone(),
837                ) $(. $aw)* ?)
838            } else {
839                None
840            };
841            let should_scorers: Vec<Box<dyn Scorer + '_>> = match should_scorer {
842                Some(scorer) => vec![scorer],
843                None => {
844                    let mut scorers = Vec::with_capacity(should.len());
845                    for query in should {
846                        scorers.push(query.$scorer_fn(
847                            reader,
848                            limit,
849                            should_options.clone(),
850                        ) $(. $aw)* ?);
851                    }
852                    scorers
853                }
854            };
855
856            if must_verifiers.is_empty() {
857                let should_scorer = build_should_scorer(should_scorers);
858                log::debug!(
859                    "BooleanQuery planner: PredicatedScorer {} preds + {} must_not_v, \
860                     SHOULD size_hint={}, combined={}",
861                    predicates.len(), must_not_verifiers.len(),
862                    should_scorer.size_hint(), combined_should
863                );
864                return Ok(Box::new(super::PredicatedScorer::new(
865                    should_scorer, predicates, Vec::new(), must_not_verifiers,
866                )));
867            }
868
869            // Scoring MUST clauses drive the conjunction; SHOULD is optional.
870            log::debug!(
871                "BooleanQuery planner: required-clause BooleanScorer {} must + {} should, \
872                 {} preds + {} must_not_v",
873                must_verifiers.len(), should_scorers.len(),
874                predicates.len(), must_not_verifiers.len()
875            );
876            let mut driver = BooleanScorer {
877                must: must_verifiers,
878                should: should_scorers,
879                must_not: Vec::new(),
880                current_doc: 0,
881            };
882            driver.current_doc = driver.find_next_match();
883            return Ok(Box::new(super::PredicatedScorer::new(
884                Box::new(driver),
885                predicates,
886                Vec::new(),
887                must_not_verifiers,
888            )));
889        }
890
891        // ── 4. Standard BooleanScorer fallback ───────────────────────────
892        let mut must_scorers = Vec::with_capacity(must.len());
893        for q in must {
894            must_scorers.push(q.$scorer_fn(
895                reader, limit, scorer_options.without_threshold()
896            ) $(. $aw)* ?);
897        }
898        let mut should_scorers = Vec::with_capacity(should.len());
899        for q in should {
900            should_scorers.push(q.$scorer_fn(
901                reader, limit, scorer_options.without_threshold()
902            ) $(. $aw)* ?);
903        }
904        let mut must_not_scorers = Vec::with_capacity(must_not.len());
905        for q in must_not {
906            must_not_scorers.push(q.$scorer_fn(
907                reader, limit, scorer_options.without_threshold()
908            ) $(. $aw)* ?);
909        }
910        let mut scorer = BooleanScorer {
911            must: must_scorers,
912            should: should_scorers,
913            must_not: must_not_scorers,
914            current_doc: 0,
915        };
916        scorer.current_doc = scorer.find_next_match();
917        Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
918    }};
919}
920
921impl Query for BooleanQuery {
922    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
923        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
924    }
925
926    fn scorer_with_options<'a>(
927        &self,
928        reader: &'a SegmentReader,
929        limit: usize,
930        options: super::ScorerOptions,
931    ) -> ScorerFuture<'a> {
932        let must = self.must.clone();
933        let should = self.should.clone();
934        let must_not = self.must_not.clone();
935        let global_stats = self
936            .global_stats
937            .clone()
938            .or_else(|| options.global_stats.clone());
939        let proximity = self.proximity;
940        let text_tuning = (self.text_heap_factor, self.max_terms);
941        Box::pin(async move {
942            boolean_plan!(
943                must,
944                should,
945                must_not,
946                global_stats.as_ref(),
947                proximity,
948                text_tuning,
949                reader,
950                limit,
951                options,
952                scorer_with_options,
953                get_postings,
954                execute,
955                await
956            )
957        })
958    }
959
960    #[cfg(feature = "sync")]
961    fn scorer_sync<'a>(
962        &self,
963        reader: &'a SegmentReader,
964        limit: usize,
965    ) -> crate::Result<Box<dyn Scorer + 'a>> {
966        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
967    }
968
969    #[cfg(feature = "sync")]
970    fn scorer_sync_with_options<'a>(
971        &self,
972        reader: &'a SegmentReader,
973        limit: usize,
974        options: super::ScorerOptions,
975    ) -> crate::Result<Box<dyn Scorer + 'a>> {
976        let global_stats = self
977            .global_stats
978            .clone()
979            .or_else(|| options.global_stats.clone());
980        boolean_plan!(
981            self.must,
982            self.should,
983            self.must_not,
984            global_stats.as_ref(),
985            self.proximity,
986            (self.text_heap_factor, self.max_terms),
987            reader,
988            limit,
989            options,
990            scorer_sync_with_options,
991            get_postings_sync,
992            execute_sync
993        )
994    }
995
996    fn text_terms(&self, out: &mut Vec<(crate::dsl::Field, Vec<u8>)>) {
997        for clause in self.must.iter().chain(&self.should).chain(&self.must_not) {
998            clause.text_terms(out);
999        }
1000    }
1001
1002    fn decompose(&self) -> super::QueryDecomposition {
1003        // LSP/0 selection depends only on the sparse scoring clauses. Pure
1004        // filters may remove documents but cannot increase their score, so a
1005        // query-global superblock plan remains valid and must be shared across
1006        // segments for filtered sparse queries too. A scoring MUST clause can
1007        // change final ordering, therefore keep that shape opaque.
1008        if self.should.is_empty() || self.must.iter().any(|query| !query.is_filter()) {
1009            return super::QueryDecomposition::Opaque;
1010        }
1011        extract_all_sparse_infos(&self.should)
1012            .map(super::QueryDecomposition::SparseTerms)
1013            .unwrap_or(super::QueryDecomposition::Opaque)
1014    }
1015
1016    fn should_children(&self) -> Option<&[Arc<dyn Query>]> {
1017        if self.must.is_empty()
1018            && self.must_not.is_empty()
1019            && !self.should.is_empty()
1020            && self.proximity.is_none()
1021            && self.text_heap_factor == 1.0
1022            && self.max_terms == 0
1023        {
1024            Some(&self.should)
1025        } else {
1026            None
1027        }
1028    }
1029
1030    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
1031        self.as_doc_bitset_with_options(reader, &super::ScorerOptions::default())
1032    }
1033
1034    fn as_doc_bitset_with_options(
1035        &self,
1036        reader: &SegmentReader,
1037        options: &super::ScorerOptions,
1038    ) -> Option<super::DocBitset> {
1039        if options.stop_if_expired() {
1040            return None;
1041        }
1042        if self.must.is_empty() && self.should.is_empty() {
1043            return None;
1044        }
1045
1046        let num_docs = reader.num_docs();
1047
1048        // MUST clauses: intersect bitsets (AND)
1049        let mut result: Option<super::DocBitset> = None;
1050        for q in &self.must {
1051            let bs = options.doc_bitset(q.as_ref(), reader)?;
1052            match result {
1053                None => result = Some(bs),
1054                Some(ref mut acc) => acc.intersect_with(&bs),
1055            }
1056        }
1057
1058        // SHOULD clauses: union bitsets (OR), then intersect with MUST result
1059        if !self.should.is_empty() {
1060            let mut should_union = super::DocBitset::new(num_docs);
1061            for q in &self.should {
1062                let bs = options.doc_bitset(q.as_ref(), reader)?;
1063                should_union.union_with(&bs);
1064            }
1065            match result {
1066                None => result = Some(should_union),
1067                Some(ref mut acc) => {
1068                    // When MUST clauses exist, SHOULD is optional (doesn't filter).
1069                    // When no MUST clauses, at least one SHOULD must match.
1070                    if self.must.is_empty() {
1071                        *acc = should_union;
1072                    }
1073                }
1074            }
1075        }
1076
1077        // MUST_NOT clauses: subtract bitsets (ANDNOT)
1078        if let Some(ref mut acc) = result {
1079            for q in &self.must_not {
1080                {
1081                    let bs = options.doc_bitset(q.as_ref(), reader)?;
1082                    acc.subtract(&bs);
1083                }
1084            }
1085        }
1086
1087        if options.stop_if_expired() {
1088            None
1089        } else {
1090            result
1091        }
1092    }
1093
1094    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
1095        // Need at least some clauses
1096        if self.must.is_empty() && self.should.is_empty() {
1097            return None;
1098        }
1099
1100        // Try converting all clauses to predicates; bail if any child can't
1101        let must_preds: Vec<_> = self
1102            .must
1103            .iter()
1104            .map(|q| q.as_doc_predicate(reader))
1105            .collect::<Option<Vec<_>>>()?;
1106        let should_preds: Vec<_> = self
1107            .should
1108            .iter()
1109            .map(|q| q.as_doc_predicate(reader))
1110            .collect::<Option<Vec<_>>>()?;
1111        let must_not_preds: Vec<_> = self
1112            .must_not
1113            .iter()
1114            .map(|q| q.as_doc_predicate(reader))
1115            .collect::<Option<Vec<_>>>()?;
1116
1117        let has_must = !must_preds.is_empty();
1118
1119        Some(Box::new(move |doc_id| {
1120            // All MUST predicates must pass
1121            if !must_preds.iter().all(|p| p(doc_id)) {
1122                return false;
1123            }
1124            // When there are no MUST clauses, at least one SHOULD must pass
1125            if !has_must && !should_preds.is_empty() && !should_preds.iter().any(|p| p(doc_id)) {
1126                return false;
1127            }
1128            // No MUST_NOT predicate should pass
1129            must_not_preds.iter().all(|p| !p(doc_id))
1130        }))
1131    }
1132
1133    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
1134        let must = self.must.clone();
1135        let should = self.should.clone();
1136
1137        Box::pin(async move {
1138            if !must.is_empty() {
1139                let mut estimates = Vec::with_capacity(must.len());
1140                for q in &must {
1141                    estimates.push(q.count_estimate(reader).await?);
1142                }
1143                estimates
1144                    .into_iter()
1145                    .min()
1146                    .ok_or_else(|| crate::Error::Corruption("Empty must clause".to_string()))
1147            } else if !should.is_empty() {
1148                let mut sum = 0u32;
1149                for q in &should {
1150                    sum = sum.saturating_add(q.count_estimate(reader).await?);
1151                }
1152                Ok(sum)
1153            } else {
1154                Ok(0)
1155            }
1156        })
1157    }
1158}
1159
1160struct BooleanScorer<'a> {
1161    must: Vec<Box<dyn Scorer + 'a>>,
1162    should: Vec<Box<dyn Scorer + 'a>>,
1163    must_not: Vec<Box<dyn Scorer + 'a>>,
1164    current_doc: DocId,
1165}
1166
1167impl BooleanScorer<'_> {
1168    fn find_next_match(&mut self) -> DocId {
1169        if self.must.is_empty() && self.should.is_empty() {
1170            return TERMINATED;
1171        }
1172
1173        loop {
1174            let candidate = if !self.must.is_empty() {
1175                let mut max_doc = self
1176                    .must
1177                    .iter()
1178                    .map(|s| s.doc())
1179                    .max()
1180                    .unwrap_or(TERMINATED);
1181
1182                if max_doc == TERMINATED {
1183                    return TERMINATED;
1184                }
1185
1186                loop {
1187                    let mut all_match = true;
1188                    for scorer in &mut self.must {
1189                        let doc = scorer.seek(max_doc);
1190                        if doc == TERMINATED {
1191                            return TERMINATED;
1192                        }
1193                        if doc > max_doc {
1194                            max_doc = doc;
1195                            all_match = false;
1196                            break;
1197                        }
1198                    }
1199                    if all_match {
1200                        break;
1201                    }
1202                }
1203                max_doc
1204            } else {
1205                self.should
1206                    .iter()
1207                    .map(|s| s.doc())
1208                    .filter(|&d| d != TERMINATED)
1209                    .min()
1210                    .unwrap_or(TERMINATED)
1211            };
1212
1213            if candidate == TERMINATED {
1214                return TERMINATED;
1215            }
1216
1217            let excluded = self.must_not.iter_mut().any(|scorer| {
1218                let doc = scorer.seek(candidate);
1219                doc == candidate
1220            });
1221
1222            if !excluded {
1223                // Seek SHOULD scorers to candidate so score() can see their contributions
1224                for scorer in &mut self.should {
1225                    scorer.seek(candidate);
1226                }
1227                self.current_doc = candidate;
1228                return candidate;
1229            }
1230
1231            // Advance past excluded candidate
1232            if !self.must.is_empty() {
1233                for scorer in &mut self.must {
1234                    scorer.advance();
1235                }
1236            } else {
1237                // For SHOULD-only: seek all scorers past the excluded candidate
1238                for scorer in &mut self.should {
1239                    if scorer.doc() <= candidate && scorer.doc() != TERMINATED {
1240                        scorer.seek(candidate + 1);
1241                    }
1242                }
1243            }
1244        }
1245    }
1246}
1247
1248impl super::docset::DocSet for BooleanScorer<'_> {
1249    fn doc(&self) -> DocId {
1250        self.current_doc
1251    }
1252
1253    fn advance(&mut self) -> DocId {
1254        if !self.must.is_empty() {
1255            for scorer in &mut self.must {
1256                scorer.advance();
1257            }
1258        } else {
1259            for scorer in &mut self.should {
1260                if scorer.doc() == self.current_doc {
1261                    scorer.advance();
1262                }
1263            }
1264        }
1265
1266        self.current_doc = self.find_next_match();
1267        self.current_doc
1268    }
1269
1270    fn seek(&mut self, target: DocId) -> DocId {
1271        for scorer in &mut self.must {
1272            scorer.seek(target);
1273        }
1274
1275        for scorer in &mut self.should {
1276            scorer.seek(target);
1277        }
1278
1279        self.current_doc = self.find_next_match();
1280        self.current_doc
1281    }
1282
1283    fn size_hint(&self) -> u32 {
1284        if !self.must.is_empty() {
1285            self.must.iter().map(|s| s.size_hint()).min().unwrap_or(0)
1286        } else {
1287            self.should.iter().map(|s| s.size_hint()).sum()
1288        }
1289    }
1290}
1291
1292impl Scorer for BooleanScorer<'_> {
1293    fn score(&self) -> Score {
1294        let mut total = 0.0;
1295
1296        for scorer in &self.must {
1297            if scorer.doc() == self.current_doc {
1298                total += scorer.score();
1299            }
1300        }
1301
1302        for scorer in &self.should {
1303            if scorer.doc() == self.current_doc {
1304                total += scorer.score();
1305            }
1306        }
1307
1308        total
1309    }
1310
1311    fn matched_positions(&self) -> Option<super::MatchedPositions> {
1312        let mut all_positions: super::MatchedPositions = Vec::new();
1313
1314        for scorer in &self.must {
1315            if scorer.doc() == self.current_doc
1316                && let Some(positions) = scorer.matched_positions()
1317            {
1318                all_positions.extend(positions);
1319            }
1320        }
1321
1322        for scorer in &self.should {
1323            if scorer.doc() == self.current_doc
1324                && let Some(positions) = scorer.matched_positions()
1325            {
1326                all_positions.extend(positions);
1327            }
1328        }
1329
1330        if all_positions.is_empty() {
1331            None
1332        } else {
1333            Some(merge_matched_positions(all_positions))
1334        }
1335    }
1336}
1337
1338/// Coalesce the position lists that several clauses reported for one field.
1339///
1340/// Two term clauses on the same chunked field each report the chunk ordinal
1341/// they matched; the union must present one entry per chunk whose score is
1342/// the sum of the clause contributions (the chunk's BM25 score), not the same
1343/// ordinal twice. Distinct positions are left untouched, so token positions of
1344/// `positions`-mode fields keep their per-term scores.
1345pub(super) fn merge_matched_positions(
1346    positions: super::MatchedPositions,
1347) -> super::MatchedPositions {
1348    if positions.len() < 2 {
1349        return positions;
1350    }
1351    let mut merged: super::MatchedPositions = Vec::with_capacity(positions.len());
1352    for (field_id, scored) in positions {
1353        match merged
1354            .iter_mut()
1355            .find(|(existing, _)| *existing == field_id)
1356        {
1357            Some((_, existing)) => existing.extend(scored),
1358            None => merged.push((field_id, scored)),
1359        }
1360    }
1361    for (_, scored) in &mut merged {
1362        if scored.len() < 2 {
1363            continue;
1364        }
1365        scored.sort_by_key(|sp| sp.position);
1366        let mut write = 0usize;
1367        for read in 1..scored.len() {
1368            if scored[read].position == scored[write].position {
1369                scored[write].score += scored[read].score;
1370            } else {
1371                write += 1;
1372                scored[write] = scored[read];
1373            }
1374        }
1375        scored.truncate(write + 1);
1376    }
1377    merged
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382    use super::*;
1383    use crate::dsl::Field;
1384    use crate::query::{QueryDecomposition, TermQuery};
1385
1386    #[test]
1387    fn test_maxscore_eligible_pure_or_same_field() {
1388        // Pure OR query with multiple terms in same field should be MaxScore-eligible
1389        let query = BooleanQuery::new()
1390            .should(TermQuery::text(Field(0), "hello"))
1391            .should(TermQuery::text(Field(0), "world"))
1392            .should(TermQuery::text(Field(0), "foo"));
1393
1394        // All clauses should return term info
1395        assert!(
1396            query
1397                .should
1398                .iter()
1399                .all(|q| matches!(q.decompose(), QueryDecomposition::TextTerm(_)))
1400        );
1401
1402        // All should be same field
1403        let infos: Vec<_> = query
1404            .should
1405            .iter()
1406            .filter_map(|q| match q.decompose() {
1407                QueryDecomposition::TextTerm(info) => Some(info),
1408                _ => None,
1409            })
1410            .collect();
1411        assert_eq!(infos.len(), 3);
1412        assert!(infos.iter().all(|i| i.field == Field(0)));
1413    }
1414
1415    #[test]
1416    fn test_maxscore_not_eligible_different_fields() {
1417        // OR query with terms in different fields should NOT use MaxScore
1418        let query = BooleanQuery::new()
1419            .should(TermQuery::text(Field(0), "hello"))
1420            .should(TermQuery::text(Field(1), "world")); // Different field!
1421
1422        let infos: Vec<_> = query
1423            .should
1424            .iter()
1425            .filter_map(|q| match q.decompose() {
1426                QueryDecomposition::TextTerm(info) => Some(info),
1427                _ => None,
1428            })
1429            .collect();
1430        assert_eq!(infos.len(), 2);
1431        // Fields are different, MaxScore should not be used
1432        assert!(infos[0].field != infos[1].field);
1433    }
1434
1435    #[test]
1436    fn test_maxscore_not_eligible_with_must() {
1437        // Query with MUST clause should NOT use MaxScore optimization
1438        let query = BooleanQuery::new()
1439            .must(TermQuery::text(Field(0), "required"))
1440            .should(TermQuery::text(Field(0), "hello"))
1441            .should(TermQuery::text(Field(0), "world"));
1442
1443        // Has MUST clause, so MaxScore optimization should not kick in
1444        assert!(!query.must.is_empty());
1445    }
1446
1447    #[test]
1448    fn test_maxscore_not_eligible_with_must_not() {
1449        // Query with MUST_NOT clause should NOT use MaxScore optimization
1450        let query = BooleanQuery::new()
1451            .should(TermQuery::text(Field(0), "hello"))
1452            .should(TermQuery::text(Field(0), "world"))
1453            .must_not(TermQuery::text(Field(0), "excluded"));
1454
1455        // Has MUST_NOT clause, so MaxScore optimization should not kick in
1456        assert!(!query.must_not.is_empty());
1457    }
1458
1459    #[test]
1460    fn test_maxscore_not_eligible_single_term() {
1461        // Single SHOULD clause should NOT use MaxScore (no benefit)
1462        let query = BooleanQuery::new().should(TermQuery::text(Field(0), "hello"));
1463
1464        // Only one term, MaxScore not beneficial
1465        assert_eq!(query.should.len(), 1);
1466    }
1467
1468    #[test]
1469    fn test_term_query_info_extraction() {
1470        let term_query = TermQuery::text(Field(42), "test");
1471        match term_query.decompose() {
1472            QueryDecomposition::TextTerm(info) => {
1473                assert_eq!(info.field, Field(42));
1474                assert_eq!(info.term, b"test");
1475            }
1476            _ => panic!("Expected TextTerm decomposition"),
1477        }
1478    }
1479
1480    #[test]
1481    fn test_boolean_query_no_term_info() {
1482        // BooleanQuery itself should not return term info
1483        let query = BooleanQuery::new().should(TermQuery::text(Field(0), "hello"));
1484
1485        assert!(matches!(query.decompose(), QueryDecomposition::Opaque));
1486    }
1487}