Skip to main content

summa_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, build_sparse_results, build_sparse_results_filtered, cap_terms,
12    chain_predicates, combine_sparse_results, compute_idf, extract_all_sparse_infos,
13    finish_chunked_text_maxscore, finish_text_maxscore, prepare_per_field_grouping,
14    prepare_text_maxscore, sparse_result_scorer, text_maxscore_allowed,
15};
16use super::{CountFuture, EmptyScorer, GlobalStats, Query, Scorer, ScorerFuture};
17
18/// Boolean query with MUST, SHOULD, and MUST_NOT clauses
19///
20/// When all clauses are SHOULD term queries on the same field, automatically
21/// uses MaxScore optimization for efficient top-k retrieval.
22#[derive(Clone)]
23pub struct BooleanQuery {
24    pub must: Vec<Arc<dyn Query>>,
25    pub should: Vec<Arc<dyn Query>>,
26    pub must_not: Vec<Arc<dyn Query>>,
27    /// Optional global statistics for cross-segment IDF
28    global_stats: Option<Arc<GlobalStats>>,
29    /// Proximity rescoring of the text MaxScore result (SHOULD terms in
30    /// query order); `None` = off.
31    proximity: Option<super::ProximityConfig>,
32    /// Approximate text MaxScore: threshold divided by `heap_factor`
33    /// (< 1 prunes beyond rank safety, like sparse). 1.0 = exact.
34    text_heap_factor: f32,
35    /// Keep only the rarest `max_terms` SHOULD text terms of a field group
36    /// (0 = all): long-query cap.
37    max_terms: usize,
38}
39
40fn shared_or_extract_sparse_infos<'a>(
41    plan: Option<&'a Arc<super::bmp::LspSegmentPlan>>,
42    should: &[Arc<dyn Query>],
43) -> Option<std::borrow::Cow<'a, [super::SparseTermQueryInfo]>> {
44    plan.map(|plan| std::borrow::Cow::Borrowed(plan.infos.as_ref()))
45        .or_else(|| extract_all_sparse_infos(should).map(std::borrow::Cow::Owned))
46}
47
48impl std::fmt::Debug for BooleanQuery {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("BooleanQuery")
51            .field("must_count", &self.must.len())
52            .field("should_count", &self.should.len())
53            .field("must_not_count", &self.must_not.len())
54            .field("has_global_stats", &self.global_stats.is_some())
55            .field("proximity", &self.proximity)
56            .finish()
57    }
58}
59
60impl std::fmt::Display for BooleanQuery {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(f, "Boolean(")?;
63        let mut first = true;
64        for q in &self.must {
65            if !first {
66                write!(f, " ")?;
67            }
68            write!(f, "+{}", q)?;
69            first = false;
70        }
71        for q in &self.should {
72            if !first {
73                write!(f, " ")?;
74            }
75            write!(f, "{}", q)?;
76            first = false;
77        }
78        for q in &self.must_not {
79            if !first {
80                write!(f, " ")?;
81            }
82            write!(f, "-{}", q)?;
83            first = false;
84        }
85        if let Some(proximity) = &self.proximity {
86            write!(f, " ~proximity({}, {})", proximity.weight, proximity.window)?;
87        }
88        if self.text_heap_factor < 1.0 {
89            write!(f, " ~heap({})", self.text_heap_factor)?;
90        }
91        if self.max_terms > 0 {
92            write!(f, " ~max_terms({})", self.max_terms)?;
93        }
94        write!(f, ")")
95    }
96}
97
98impl Default for BooleanQuery {
99    fn default() -> Self {
100        Self {
101            must: Vec::new(),
102            should: Vec::new(),
103            must_not: Vec::new(),
104            global_stats: None,
105            proximity: None,
106            text_heap_factor: 1.0,
107            max_terms: 0,
108        }
109    }
110}
111
112impl BooleanQuery {
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    pub fn must(mut self, query: impl Query + 'static) -> Self {
118        self.must.push(Arc::new(query));
119        self
120    }
121
122    pub fn should(mut self, query: impl Query + 'static) -> Self {
123        self.should.push(Arc::new(query));
124        self
125    }
126
127    pub fn must_not(mut self, query: impl Query + 'static) -> Self {
128        self.must_not.push(Arc::new(query));
129        self
130    }
131
132    /// Set global statistics for cross-segment IDF
133    pub fn with_global_stats(mut self, stats: Arc<GlobalStats>) -> Self {
134        self.global_stats = Some(stats);
135        self
136    }
137
138    /// Rescore the text MaxScore top candidates with term proximity
139    /// (`docs`: `query::proximity`). Applies when the SHOULD clauses are text
140    /// terms of one field, in query order.
141    pub fn with_proximity(mut self, config: super::ProximityConfig) -> Self {
142        self.proximity = config.is_active().then_some(config);
143        self
144    }
145
146    /// Approximate text MaxScore (threshold / `heap_factor`), like sparse.
147    /// 1 is exact; [0, 1) prunes more aggressively, with an effective 0.01
148    /// floor. Non-finite values and values outside [0, 1] fail construction
149    /// of the scorer. RPC zero/unset is normalized to 1 by the adapter.
150    pub fn with_text_heap_factor(mut self, heap_factor: f32) -> Self {
151        self.text_heap_factor = heap_factor;
152        self
153    }
154
155    /// Cap the text terms scored per field group to the `max_terms` rarest
156    /// (highest idf) ones; 0 = no cap.
157    pub fn with_max_terms(mut self, max_terms: usize) -> Self {
158        self.max_terms = max_terms;
159        self
160    }
161}
162
163/// Flatten nested pure-SHOULD Boolean queries into one SHOULD list.
164///
165/// `OR(OR(a, b), c)` scores exactly like `OR(a, b, c)`, and only the flat
166/// form reaches MaxScore and filter push-down. The nested form would be an
167/// opaque sub-scorer whose top-k truncation can hide matches from the outer
168/// query.
169fn flatten_should(should: &[Arc<dyn Query>]) -> std::borrow::Cow<'_, [Arc<dyn Query>]> {
170    if !should.iter().any(|query| query.should_children().is_some()) {
171        return std::borrow::Cow::Borrowed(should);
172    }
173
174    fn push_flat(out: &mut Vec<Arc<dyn Query>>, query: &Arc<dyn Query>) {
175        match query.should_children() {
176            Some(children) => children.iter().for_each(|child| push_flat(out, child)),
177            None => out.push(Arc::clone(query)),
178        }
179    }
180
181    let mut flat = Vec::with_capacity(should.len());
182    should.iter().for_each(|query| push_flat(&mut flat, query));
183    std::borrow::Cow::Owned(flat)
184}
185
186/// Build a SHOULD-only scorer from a vec of optimized scorers.
187fn build_should_scorer<'a>(scorers: Vec<Box<dyn Scorer + 'a>>) -> Box<dyn Scorer + 'a> {
188    if scorers.is_empty() {
189        return Box::new(EmptyScorer);
190    }
191    if scorers.len() == 1 {
192        return scorers.into_iter().next().unwrap();
193    }
194    let mut scorer = BooleanScorer {
195        must: vec![],
196        should: scorers,
197        must_not: vec![],
198        current_doc: 0,
199        lead: 0,
200        doc_limit: 0,
201    };
202    scorer.initialize();
203    Box::new(scorer)
204}
205
206// ── Planner macro ────────────────────────────────────────────────────────
207//
208// Unified planner for both async and sync paths.  Parameterised on:
209//   $scorer_fn      – scorer_with_options | scorer_sync_with_options
210//   $get_postings_fn – get_postings | get_postings_sync
211//   $execute_fn     – execute | execute_sync
212//   $($aw)*         – .await  (present for async, absent for sync)
213//
214// Decision order:
215//   1. Single-clause unwrap
216//   2. Pure OR → text MaxScore | sparse MaxScore | per-field MaxScore
217//   3. Filter push-down → predicate-aware sparse MaxScore | PredicatedScorer
218//   4. Standard BooleanScorer fallback
219macro_rules! boolean_plan {
220    ($must:expr, $should:expr, $must_not:expr, $global_stats:expr, $proximity:expr, $text_tuning:expr,
221     $reader:expr, $limit:expr, $scorer_options:expr,
222     $scorer_fn:ident, $get_postings_fn:ident, $execute_fn:ident
223     $(, $aw:tt)*) => {{
224        let must: &[Arc<dyn Query>] = &$must;
225        let should_flat = flatten_should(&$should);
226        let should_all: &[Arc<dyn Query>] = &should_flat;
227        let must_not: &[Arc<dyn Query>] = &$must_not;
228        let global_stats: Option<&Arc<GlobalStats>> = $global_stats;
229        let reader: &SegmentReader = $reader;
230        let limit: usize = $limit;
231        let mut scorer_options: super::ScorerOptions = $scorer_options;
232        // A Boolean node's resolved statistics apply to its complete child
233        // streams too. Explicit statistics on a child still take precedence.
234        scorer_options.global_stats = global_stats.cloned();
235        if !$text_tuning.0.is_finite() || !(0.0..=1.0).contains(&$text_tuning.0) {
236            return Err(crate::Error::Query(
237                "Text heap_factor must be finite and between 0 and 1".into(),
238            ));
239        }
240        if scorer_options.stop_if_expired() {
241            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
242        }
243
244        // Cap SHOULD clauses to MAX_QUERY_TERMS, but only count queries that need
245        // posting-list cursors. Fast-field predicates (O(1) per doc) are exempt.
246        let should_capped: Vec<Arc<dyn Query>>;
247        let should: &[Arc<dyn Query>] = if should_all.len() > super::MAX_QUERY_TERMS {
248            let is_predicate: Vec<bool> = should_all
249                .iter()
250                .map(|q| q.is_filter() || q.as_doc_predicate(reader).is_some())
251                .collect();
252            let cursor_count = is_predicate.iter().filter(|&&p| !p).count();
253
254            if cursor_count > super::MAX_QUERY_TERMS {
255                let mut kept = Vec::with_capacity(should_all.len());
256                let mut cursor_kept = 0usize;
257                for (q, &is_pred) in should_all.iter().zip(is_predicate.iter()) {
258                    if is_pred {
259                        kept.push(q.clone());
260                    } else if cursor_kept < super::MAX_QUERY_TERMS {
261                        kept.push(q.clone());
262                        cursor_kept += 1;
263                    }
264                }
265                log::warn!(
266                    "BooleanQuery: capping cursor SHOULD from {} to {} ({} fast-field predicates exempt); dropped clauses do not match or score",
267                    cursor_count,
268                    super::MAX_QUERY_TERMS,
269                    kept.len() - cursor_kept,
270                );
271                should_capped = kept;
272                &should_capped
273            } else {
274                log::debug!(
275                    "BooleanQuery: {} SHOULD clauses OK ({} need cursors, {} fast-field predicates)",
276                    should_all.len(),
277                    cursor_count,
278                    should_all.len() - cursor_count,
279                );
280                should_all
281            }
282        } else {
283            should_all
284        };
285
286        // ── 1. Single-clause optimisation ────────────────────────────────
287        if must_not.is_empty() {
288            if must.len() == 1 && should.is_empty() {
289                return must[0].$scorer_fn(reader, limit, scorer_options) $(.  $aw)* ;
290            }
291            if should.len() == 1 && must.is_empty() && $text_tuning.0 == 1.0 {
292                return should[0].$scorer_fn(reader, limit, scorer_options) $(. $aw)* ;
293            }
294        }
295
296        if (scorer_options.complete_text_matches || scorer_options.physical_text_field.is_some()) && must.is_empty() && must_not.is_empty()
297            && !should.is_empty()
298            && let Some((infos, field, avg_field_len, num_docs)) = prepare_text_maxscore(should, reader, global_stats)
299            && text_maxscore_allowed(reader, field, scorer_options.collect_positions)
300        {
301            if $proximity.is_some() {
302                return Err(crate::Error::Query("proximity scoring inside a required text clause is not supported".into()));
303            }
304            let mut postings = Vec::with_capacity(infos.len());
305            for info in infos {
306                if let Some(pl) = reader.$get_postings_fn(info.field, &info.term) $(. $aw)* ? {
307                    let idf = compute_idf(&pl, field, &info.term, num_docs, global_stats) * info.weight;
308                    postings.push((pl, idf));
309                }
310            }
311            return super::term::complete_text_scorer(postings, avg_field_len, reader, field, &scorer_options);
312        }
313
314        // Plain ranked text terms on one field share the bounded text window
315        // executor. Semantic membership (every MUST term, plus optional SHOULD
316        // terms) is imposed inside the executor before its top-k cutoff.
317        // Complete streams, positions, boosts, tuning, proximity, chunked
318        // fields and compositions with their own semantics stay below.
319        let counted_limit = scorer_options.ranked_count_limit.filter(|_| should.is_empty()
320            && scorer_options.shared_threshold.as_ref().and_then(super::SharedThreshold::deadline).is_none());
321        let ranked_limit = counted_limit.unwrap_or(limit);
322        if !must.is_empty() && must_not.is_empty()
323            && must.len() + should.len() <= super::MAX_QUERY_TERMS
324            && ranked_limit < reader.num_docs() as usize
325            && (!scorer_options.complete_text_matches || counted_limit.is_some()) && !scorer_options.collect_positions
326            && $proximity.is_none() && $text_tuning.0 == 1.0 && $text_tuning.1 == 0
327            && let Some((required, field, _, _)) = prepare_text_maxscore(must, reader, global_stats)
328            && let Some(optional) = ranked_optional_terms(should, field, reader, global_stats)
329            && required.iter().chain(&optional).all(|info| info.weight == 1.0)
330            && (!reader.has_text_mapping(field)
331                || (scorer_options.physical_text_field == Some(field) && reader.alive_docs().is_none()))
332        {
333            let required_count = required.len();
334            let params = super::Bm25Params::for_field(reader.schema(), field);
335            let mut cursors = Vec::with_capacity(required_count + optional.len());
336            for (index, info) in required.into_iter().chain(optional).enumerate() {
337                let Some(postings) = reader.$get_postings_fn(field, &info.term) $(. $aw)* ? else {
338                    if index < required_count {
339                        log::debug!("BooleanQuery planner: required term absent → empty result");
340                        return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
341                    }
342                    continue;
343                };
344                let (idf, avg_len) = super::term::compute_term_idf(
345                    &postings, field, reader, global_stats, &info.term,
346                );
347                cursors.push(super::TermCursor::text_with_params(
348                    postings, idf, avg_len,
349                    reader.chunk_map(field).map(super::LengthSource::Chunks)
350                        .or_else(|| reader.doc_lengths(field).map(super::LengthSource::Docs)), params,
351                ));
352            }
353            log::debug!(
354                "BooleanQuery planner: ranked text windows, {} required + {} optional terms",
355                required_count,
356                cursors.len() - required_count
357            );
358            let executor = ranked_text_executor(cursors, required_count, ranked_limit, reader, field, &scorer_options);
359            if counted_limit.is_some() {
360                let (mut results, count) = executor.execute_counted_conjunction()?;
361                super::text_mapping::physical_results(&mut results, reader, scorer_options.physical_text_field);
362                return Ok(Box::new(super::planner::TopKResultScorer::new(results).with_exact_count(count)) as Box<dyn Scorer + '_>);
363            }
364            let mut results = executor.$execute_fn() $(. $aw)* ?;
365            super::text_mapping::physical_results(&mut results, reader, scorer_options.physical_text_field);
366            return Ok(Box::new(super::planner::TopKResultScorer::new(results)) as Box<dyn Scorer + '_>);
367        }
368
369        // Other physical compositions keep complete child streams.
370        if scorer_options.physical_text_field.is_none() {
371        // ── 2. Pure OR → MaxScore optimisations ──────────────────────────
372        if must.is_empty() && must_not.is_empty()
373            && (should.len() >= 2 || (should.len() == 1 && $text_tuning.0 < 1.0)) {
374            // 2a. Text MaxScore (single-field, all term queries)
375            if !scorer_options.complete_text_matches
376                && let Some((mut infos, text_field, avg_field_len, num_docs)) =
377                prepare_text_maxscore(should, reader, global_stats)
378                && text_maxscore_allowed(reader, text_field, scorer_options.collect_positions)
379            {
380                let mut posting_lists = Vec::with_capacity(infos.len());
381                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
382                for info in infos.drain(..) {
383                    if let Some(pl) = reader.$get_postings_fn(info.field, &info.term)
384                        $(. $aw)* ?
385                    {
386                        let idf = compute_idf(&pl, info.field, &info.term, num_docs, global_stats) * info.weight;
387                        posting_lists.push((pl, idf));
388                        term_bytes.push(info.term.clone());
389                    }
390                }
391                cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
392                // Chunked field: score chunks, fold to documents with ordinals.
393                if reader.has_text_mapping(text_field) {
394                    return finish_chunked_text_maxscore(
395                        posting_lists, avg_field_len, limit, reader, text_field, eligibility_predicate(&scorer_options),
396                        $proximity.map(|config| (config, term_bytes)),
397                        $text_tuning.0,
398                        scorer_options.shared_threshold.as_ref(),
399                    );
400                }
401                // Seed from the cross-segment floor: this path scores final
402                // per-doc BM25 into a top-`limit` heap, so a floor carried from
403                // an already-searched segment prunes exactly (see
404                // SharedThreshold). The per-field path below stays at 0.0 —
405                // its per-field partial scores are not the final doc score.
406                let shared_threshold = std::cell::Cell::new(scorer_options.initial_threshold);
407                return finish_text_maxscore(
408                    posting_lists,
409                    avg_field_len,
410                    reader.doc_lengths(text_field),
411                    limit,
412                    &shared_threshold,
413                    reader,
414                    text_field,
415                    eligibility_predicate(&scorer_options),
416                    super::Bm25Params::for_field(reader.schema(), text_field),
417                    $proximity.map(|config| (config, term_bytes)),
418                    $text_tuning.0,
419                    scorer_options.shared_threshold.as_ref(),
420                );
421            }
422
423            // 2b. Sparse (single-field, all sparse term queries)
424            // Auto-detect: BMP executor if field has BMP index, else MaxScore
425            if let Some(infos) =
426                shared_or_extract_sparse_infos(scorer_options.lsp_plan.as_ref(), should)
427            {
428                if !scorer_options.complete_text_matches
429                    && let Some((raw, info)) = build_sparse_results(&infos, reader, limit, &scorer_options)?
430                {
431                    return Ok(sparse_result_scorer(raw, info.field));
432                }
433                if let Some((raw, info)) =
434                    build_sparse_bmp_results(&infos, reader, limit, &scorer_options)?
435                {
436                    return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
437                }
438                if let Some((executor, info)) =
439                    build_sparse_maxscore_executor(&infos, reader, limit, None, &scorer_options)
440                {
441                    let raw = executor.$execute_fn() $(. $aw)* ?;
442                    return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
443                }
444            }
445
446            // 2c. Per-field text MaxScore (multi-field term grouping)
447            if !scorer_options.complete_text_matches
448                && let Some(grouping) = prepare_per_field_grouping(
449                should,
450                reader,
451                limit,
452                global_stats,
453                scorer_options.collect_positions,
454            ) {
455                let mut scorers: Vec<Box<dyn Scorer + '_>> = Vec::new();
456                // Query-local cross-group threshold seeding (see finish_text_maxscore)
457                let shared_threshold = std::cell::Cell::new(0.0f32);
458                for (field, avg_field_len, infos) in &grouping.multi_term_groups {
459                    // Chunked fields: IDF over chunks, not documents.
460                    let corpus_size = reader.text_corpus_size(*field);
461                    let mut posting_lists = Vec::with_capacity(infos.len());
462                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
463                    for info in infos {
464                        if let Some(pl) = reader.$get_postings_fn(info.field, &info.term)
465                            $(. $aw)* ?
466                        {
467                            let idf = compute_idf(
468                                &pl, *field, &info.term, corpus_size, global_stats,
469                            ) * info.weight;
470                            posting_lists.push((pl, idf));
471                        term_bytes.push(info.term.clone());
472                        }
473                    }
474                    cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
475                    if reader.has_text_mapping(*field) {
476                        scorers.push(finish_chunked_text_maxscore(
477                            posting_lists,
478                            *avg_field_len,
479                            grouping.per_field_limit,
480                            reader,
481                            *field,
482                            eligibility_predicate(&scorer_options),
483                            $proximity.map(|config| (config, term_bytes)),
484                            $text_tuning.0,
485                            scorer_options.shared_threshold.as_ref(),
486                        )?);
487                    } else if !posting_lists.is_empty() {
488                        scorers.push(finish_text_maxscore(
489                            posting_lists,
490                            *avg_field_len,
491                            reader.doc_lengths(*field),
492                            grouping.per_field_limit,
493                            &shared_threshold,
494                            reader,
495                            *field,
496                            eligibility_predicate(&scorer_options),
497                            super::Bm25Params::for_field(reader.schema(), *field),
498                            $proximity.map(|config| (config, term_bytes)),
499                            $text_tuning.0,
500                            scorer_options.shared_threshold.as_ref(),
501                        )?);
502                    }
503                }
504                // A child's own top-k is not a safe candidate set for a summed
505                // parent: request its complete stream.
506                for &idx in &grouping.fallback_indices {
507                    scorers.push(should[idx].$scorer_fn(
508                        reader,
509                        limit,
510                        scorer_options.for_required_clause(),
511                    ) $(. $aw)* ?);
512                }
513                return Ok(build_should_scorer(scorers));
514            }
515        }
516
517        // ── 3. Filter push-down (MUST + SHOULD) ─────────────────────────
518        //
519        // Position collection no longer disables this path: fast-field
520        // predicates carry no positions to lose and verifier scorers keep
521        // theirs. Only the posting-list bitset shortcut is skipped when
522        // positions are requested, because a bitset cannot report them.
523        if (!scorer_options.complete_text_matches || extract_all_sparse_infos(should).is_some())
524            && !should.is_empty() && (!must.is_empty() || !must_not.is_empty()) {
525            // ── 3-text. Text SHOULD with materializable filters ──────────
526            //
527            // When every SHOULD clause is a text term and the MUST/MUST_NOT
528            // clauses combine into one document bitset (term filters, ranges,
529            // quoted phrases via `PhraseQuery::as_doc_bitset`), the text
530            // MaxScore executors run with the bitset as a predicate: the
531            // top-k is exact over the filtered documents (bounds are unaffected
532            // by a filter), instead of an over-fetched unfiltered top-k that a
533            // PredicatedScorer thins out afterwards. Documents matching only
534            // the filters (score 0) fill the tail when fewer than `limit`
535            // scored documents survive, keeping Boolean semantics.
536            let text_groups: Option<Vec<(crate::Field, Vec<super::TermQueryInfo>)>> = {
537                let mut groups: Vec<(crate::Field, Vec<super::TermQueryInfo>)> = Vec::new();
538                let mut all_text = true;
539                for q in should {
540                    match q.decompose() {
541                        super::QueryDecomposition::TextTerm(info)
542                            if info.global_stats.is_none() && text_maxscore_allowed(
543                                reader, info.field, scorer_options.collect_positions,
544                            ) =>
545                        {
546                            match groups.iter_mut().find(|(f, _)| *f == info.field) {
547                                Some((_, infos)) => infos.push(info),
548                                None => groups.push((info.field, vec![info])),
549                            }
550                        }
551                        _ => {
552                            all_text = false;
553                            break;
554                        }
555                    }
556                }
557                all_text.then_some(groups)
558            };
559            if must.iter().all(|query| {
560                query.is_filter()
561                    || query.as_doc_predicate(reader).is_some()
562                    || (!matches!(
563                        query.decompose(),
564                        super::QueryDecomposition::TextTerm(_)
565                    ) && scorer_options.doc_bitset(query.as_ref(), reader).is_some())
566            })
567                && let Some(groups) = text_groups
568                && (groups.len() == 1
569                    || ($proximity.is_none()
570                        && groups
571                            .iter()
572                            .all(|(field, _)| !reader.has_text_mapping(*field))))
573                && let Some(bitset) = build_combined_bitset(must, must_not, reader, &scorer_options)
574            {
575                if scorer_options.stop_if_expired() {
576                    return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
577                }
578                let bitset = std::sync::Arc::new(bitset);
579                let single_field = groups.len() == 1;
580
581                // Scores from different fields are additive. Running a
582                // separate top-k per field and merging those windows is not
583                // exact: a document just below every local cutoff can still
584                // win after its field scores are summed. Non-chunked text
585                // fields share document ids, so put all of their cursors in
586                // one executor and apply the filter there.
587                if !single_field {
588                    let mut cursors = Vec::new();
589                    for (field, infos) in groups {
590                        let corpus_size = reader.text_corpus_size(field);
591                        let avg_field_len = global_stats
592                            .map(|stats| stats.avg_field_len(field))
593                            .unwrap_or_else(|| reader.avg_field_len(field));
594                        let params = super::Bm25Params::for_field(reader.schema(), field);
595                        let mut posting_lists = Vec::with_capacity(infos.len());
596                        let mut term_bytes = Vec::with_capacity(infos.len());
597                        for info in &infos {
598                            if let Some(postings) =
599                                reader.$get_postings_fn(field, &info.term) $(. $aw)* ?
600                            {
601                                let idf = compute_idf(
602                                    &postings,
603                                    field,
604                                    &info.term,
605                                    corpus_size,
606                                    global_stats,
607                                ) * info.weight;
608                                posting_lists.push((postings, idf));
609                                term_bytes.push(info.term.clone());
610                            }
611                        }
612                        cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
613                        cursors.extend(posting_lists.into_iter().map(|(postings, idf)| {
614                            super::TermCursor::text_with_params(
615                                postings,
616                                idf,
617                                avg_field_len,
618                                reader.doc_lengths(field).map(super::LengthSource::Docs),
619                                params,
620                            )
621                        }));
622                    }
623
624                    let filter = bitset.clone();
625                    let predicate: super::DocPredicate<'_> =
626                        Box::new(move |doc_id| filter.contains(doc_id));
627                    let mut executor = super::MaxScoreExecutor::new(
628                        cursors,
629                        limit,
630                        $text_tuning.0,
631                    )
632                    .with_metric_labels(reader.schema().index_label(), "<multiple>")
633                    .with_predicate(predicate)
634                    .with_budget(scorer_options.shared_threshold.clone());
635                    if $text_tuning.0 == 1.0 && scorer_options.initial_threshold > 0.0 {
636                        executor.seed_threshold(scorer_options.initial_threshold);
637                    }
638                    let results = executor.execute_sync()?;
639                    let found = results.len() as u32;
640                    let should_scorer: Box<dyn Scorer + '_> =
641                        Box::new(super::planner::TopKResultScorer::new(results));
642                    if !must.is_empty() && (found as usize) < limit && bitset.count() > found {
643                        return Ok(Box::new(super::planner::BitsetFillScorer::new(
644                            should_scorer,
645                            bitset,
646                        )));
647                    }
648                    return Ok(should_scorer);
649                }
650
651                let group_limit = if single_field {
652                    limit
653                } else {
654                    super::max_candidate_limit(limit)
655                        .min(reader.num_docs() as usize)
656                        .max(1)
657                };
658                // Cross-segment floor only when the group score is the final
659                // document score (single field); per-field partial scores
660                // start at 0.0 like path 2c.
661                let shared_threshold = std::cell::Cell::new(if single_field {
662                    scorer_options.initial_threshold
663                } else {
664                    0.0
665                });
666                let mut scorers: Vec<Box<dyn Scorer + '_>> = Vec::new();
667                let mut found = 0u32;
668                let mut complete = true;
669                for (field, infos) in groups {
670                    let corpus_size = reader.text_corpus_size(field);
671                    let avg_field_len = global_stats
672                        .map(|s| s.avg_field_len(field))
673                        .unwrap_or_else(|| reader.avg_field_len(field));
674                    let mut posting_lists = Vec::with_capacity(infos.len());
675                let mut term_bytes: Vec<Vec<u8>> = Vec::new();
676                    for info in &infos {
677                        if let Some(pl) = reader.$get_postings_fn(field, &info.term) $(. $aw)* ? {
678                            let idf = compute_idf(&pl, field, &info.term, corpus_size, global_stats) * info.weight;
679                            posting_lists.push((pl, idf));
680                        term_bytes.push(info.term.clone());
681                        }
682                    }
683                    cap_terms(&mut posting_lists, &mut term_bytes, $text_tuning.1);
684                    let filter = bitset.clone();
685                    let predicate: super::DocPredicate<'_> =
686                        Box::new(move |doc_id| filter.contains(doc_id));
687                    let scorer = if reader.has_text_mapping(field) {
688                        finish_chunked_text_maxscore(
689                            posting_lists, avg_field_len, group_limit, reader, field, Some(predicate),
690                            $proximity.map(|config| (config, term_bytes)),
691                            $text_tuning.0,
692                            scorer_options.shared_threshold.as_ref(),
693                        )?
694                    } else {
695                        finish_text_maxscore(
696                            posting_lists,
697                            avg_field_len,
698                            reader.doc_lengths(field),
699                            group_limit,
700                            &shared_threshold,
701                            reader,
702                            field,
703                            Some(predicate),
704                            super::Bm25Params::for_field(reader.schema(), field),
705                            $proximity.map(|config| (config, term_bytes)),
706                            $text_tuning.0,
707                            scorer_options.shared_threshold.as_ref(),
708                        )?
709                    };
710                    let hits = scorer.size_hint();
711                    found = found.saturating_add(hits);
712                    if hits as usize >= group_limit {
713                        complete = false;
714                    }
715                    scorers.push(scorer);
716                }
717                log::debug!(
718                    "BooleanQuery planner: bitset-aware text MaxScore, {} field group(s), \
719                     {} filtered docs, {} scored hits",
720                    scorers.len(),
721                    bitset.count(),
722                    found
723                );
724                let should_scorer = build_should_scorer(scorers);
725                if !must.is_empty()
726                    && complete
727                    && (found as usize) < limit
728                    && bitset.count() > found
729                {
730                    return Ok(Box::new(super::planner::BitsetFillScorer::new(
731                        should_scorer,
732                        bitset,
733                    )));
734                }
735                return Ok(should_scorer);
736            }
737
738            // Pre-check: is SHOULD all-sparse? This determines whether we can
739            // use bitset fallback for MUST clauses that lack fast-field predicates.
740            // For sparse SHOULD, the predicate is pushed into BMP/MaxScore traversal
741            // so all qualifying docs are found. For text SHOULD, we must NOT convert
742            // MUST to a predicate (PredicatedScorer would drop MUST-only docs that
743            // don't match SHOULD), so those go to verifier → BooleanScorer.
744            let should_is_sparse = scorer_options.lsp_plan.is_some()
745                || extract_all_sparse_infos(should).is_some();
746            let bitset_predicates_allowed = should_is_sparse && !scorer_options.collect_positions;
747
748            // 3a. Compile MUST → predicates (O(1)) vs verifier scorers (seek)
749            //
750            // Priority: as_doc_predicate (fast-field O(1)) > as_doc_bitset
751            // (posting-list materialization, O(1) lookup, sparse-SHOULD only)
752            // > verifier scorer (seek).
753            super::planner::push_down_text_predicates(
754                must, should, must_not, reader, &mut scorer_options,
755            )?;
756            if scorer_options.stop_if_expired()
757                || scorer_options.eligibility.as_ref()
758                    .is_some_and(|bits| bits.next_set_bit(0).is_none()) {
759                return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
760            }
761            let mut predicates: Vec<super::DocPredicate<'_>> = Vec::new();
762            let mut must_verifiers: Vec<Box<dyn super::Scorer + '_>> = Vec::new();
763            for q in must {
764                if let Some(pred) = q.as_doc_predicate(reader) {
765                    log::debug!("BooleanQuery planner 3a: MUST clause → predicate ({})", q);
766                    predicates.push(pred);
767                } else if bitset_predicates_allowed {
768                    if let Some(bitset) = scorer_options.doc_bitset(q.as_ref(), reader) {
769                        log::debug!("BooleanQuery planner 3a: MUST clause → bitset predicate ({})", q);
770                        predicates.push(Box::new(move |doc_id| bitset.contains(doc_id)));
771                    } else {
772                        log::debug!("BooleanQuery planner 3a: MUST clause → verifier scorer ({})", q);
773                        must_verifiers.push(q.$scorer_fn(
774                            reader, limit, scorer_options.for_required_clause()
775                        ) $(. $aw)* ?);
776                    }
777                } else {
778                    log::debug!("BooleanQuery planner 3a: MUST clause → verifier scorer ({})", q);
779                    must_verifiers.push(q.$scorer_fn(
780                        reader, limit, scorer_options.for_required_clause()
781                    ) $(. $aw)* ?);
782                }
783            }
784            // Compile MUST_NOT → negated predicates vs verifier scorers
785            let mut must_not_verifiers: Vec<Box<dyn super::Scorer + '_>> = Vec::new();
786            for q in must_not {
787                if let Some(pred) = q.as_doc_predicate(reader) {
788                    let negated: super::DocPredicate<'_> =
789                        Box::new(move |doc_id| !pred(doc_id));
790                    predicates.push(negated);
791                } else if bitset_predicates_allowed {
792                    if let Some(bitset) = scorer_options.doc_bitset(q.as_ref(), reader) {
793                        log::debug!("BooleanQuery planner 3a: MUST_NOT clause → bitset predicate ({})", q);
794                        predicates.push(Box::new(move |doc_id| !bitset.contains(doc_id)));
795                    } else {
796                        must_not_verifiers.push(q.$scorer_fn(
797                            reader, limit, scorer_options.for_required_clause()
798                        ) $(. $aw)* ?);
799                    }
800                } else {
801                    must_not_verifiers.push(q.$scorer_fn(
802                        reader, limit, scorer_options.for_required_clause()
803                    ) $(. $aw)* ?);
804                }
805            }
806
807            // 3b. Fast path: pure predicates + sparse SHOULD → BMP or MaxScore w/ predicate
808            if scorer_options.stop_if_expired() {
809                return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
810            }
811            if must_verifiers.is_empty()
812                && must_not_verifiers.is_empty()
813                && !predicates.is_empty()
814            {
815                let sparse_infos =
816                    shared_or_extract_sparse_infos(scorer_options.lsp_plan.as_ref(), should);
817                if let Some(infos) = sparse_infos {
818                    // Try BMP with bitset first: build compact bitset from MUST/MUST_NOT
819                    // posting lists (O(M) for term queries) for fast per-slot lookup.
820                    let bitset_result = build_combined_bitset(must, must_not, reader, &scorer_options);
821                    if scorer_options.stop_if_expired() {
822                        return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
823                    }
824                    if let Some(ref bitset) = bitset_result {
825                        let bitset_pred = |doc_id: crate::DocId| bitset.contains(doc_id);
826                        if !scorer_options.complete_text_matches
827                            && let Some((raw, info)) = build_sparse_results_filtered(
828                                &infos, reader, limit, &bitset_pred, &scorer_options
829                            )?
830                        {
831                            return Ok(sparse_result_scorer(raw, info.field));
832                        }
833                        if let Some((raw, info)) =
834                            build_sparse_bmp_results_filtered(
835                                &infos, reader, limit, &bitset_pred, &scorer_options
836                            )?
837                        {
838                            log::debug!(
839                                "BooleanQuery planner: bitset-aware sparse BMP, {} dims, {} matching docs",
840                                infos.len(),
841                                bitset.count()
842                            );
843                            return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
844                        }
845                    }
846
847                    // Fallback: closure predicate (for queries that don't support bitsets)
848                    let combined = chain_predicates(predicates);
849                    if !scorer_options.complete_text_matches
850                        && let Some((raw, info)) = build_sparse_results_filtered(
851                            &infos, reader, limit, &*combined, &scorer_options
852                        )?
853                    {
854                        return Ok(sparse_result_scorer(raw, info.field));
855                    }
856                    if let Some((raw, info)) =
857                        build_sparse_bmp_results_filtered(
858                            &infos, reader, limit, &*combined, &scorer_options
859                        )?
860                    {
861                        log::debug!(
862                            "BooleanQuery planner: predicate-aware sparse BMP, {} dims",
863                            infos.len()
864                        );
865                        return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
866                    }
867                    // Try MaxScore with predicate
868                    if let Some((executor, info)) =
869                        build_sparse_maxscore_executor(&infos, reader, limit, Some(combined), &scorer_options)
870                    {
871                        log::debug!(
872                            "BooleanQuery planner: predicate-aware sparse MaxScore, {} dims",
873                            infos.len()
874                        );
875                        let raw = executor.$execute_fn() $(. $aw)* ?;
876                        return Ok(combine_sparse_results(raw, info.combiner, info.field, limit));
877                    }
878                    // predicates consumed — cannot fall through; rebuild them
879                    // (this path only triggers if neither sparse index exists)
880                    // should_is_sparse is true here (we're inside extract_all_sparse_infos)
881                    predicates = Vec::new();
882                    for q in must {
883                        if let Some(pred) = q.as_doc_predicate(reader) {
884                            predicates.push(pred);
885                        } else if let Some(bitset) = scorer_options.doc_bitset(q.as_ref(), reader) {
886                            predicates.push(Box::new(move |doc_id| bitset.contains(doc_id)));
887                        }
888                    }
889                    for q in must_not {
890                        if let Some(pred) = q.as_doc_predicate(reader) {
891                            let negated: super::DocPredicate<'_> =
892                                Box::new(move |doc_id| !pred(doc_id));
893                            predicates.push(negated);
894                        } else if let Some(bitset) = scorer_options.doc_bitset(q.as_ref(), reader) {
895                            predicates.push(Box::new(move |doc_id| !bitset.contains(doc_id)));
896                        }
897                    }
898                }
899            }
900
901            // 3c. Generic fallback — never filter a truncated SHOULD window.
902            // Sparse retrieval keeps its combined candidate executor. Other
903            // query shapes use the individual SHOULD streams so filters and
904            // scoring requirements see the complete document streams.
905            let mut should_options = if must_verifiers.is_empty() && must_not_verifiers.is_empty() {
906                scorer_options.without_threshold()
907            } else {
908                scorer_options.for_required_clause()
909            };
910            if should_is_sparse {
911                // The outer decomposition built this plan from the complete
912                // sparse SHOULD expression. Filters cannot increase scores,
913                // so retain global γ even when a verifier prevents predicate
914                // push-down. Thresholds still belong to the outer score space
915                // and remain cleared.
916                should_options.lsp_plan = scorer_options.lsp_plan.clone();
917            }
918            let proximity_should = $proximity.is_some();
919            if proximity_should {
920                // This existing path explicitly uses the full text corpus as
921                // sub_limit below, so it already preserves required matches.
922                should_options.complete_text_matches = false;
923            }
924            let combined_should = should.len() == 1 || should_is_sparse || proximity_should;
925            let should_scorer: Option<Box<dyn Scorer + '_>> = if should.len() == 1 {
926                Some(should[0].$scorer_fn(reader, limit, should_options.clone()) $(. $aw)* ?)
927            } else if should_is_sparse || proximity_should {
928                let sub = BooleanQuery {
929                    must: Vec::new(),
930                    should: should.to_vec(),
931                    must_not: Vec::new(),
932                    global_stats: global_stats.cloned(),
933                    proximity: $proximity,
934                    text_heap_factor: $text_tuning.0,
935                    max_terms: $text_tuning.1,
936                };
937                // Proximity is a positive second-stage bonus. Preserve the
938                // complete SHOULD stream before applying outer requirements;
939                // a bounded BM25-only window can omit the document whose
940                // proximity bonus would promote it. Chunked fields use their
941                // virtual-id corpus size, plain fields their document count.
942                let sub_limit = if proximity_should {
943                    should
944                        .first()
945                        .and_then(|query| match query.decompose() {
946                            super::QueryDecomposition::TextTerm(info) => {
947                                Some(reader.text_corpus_size(info.field) as usize)
948                            }
949                            _ => None,
950                        })
951                        .unwrap_or(reader.num_docs() as usize)
952                        .max(limit)
953                } else {
954                    super::max_candidate_limit(limit)
955                };
956                Some(sub.$scorer_fn(
957                    reader,
958                    sub_limit,
959                    should_options.clone(),
960                ) $(. $aw)* ?)
961            } else {
962                None
963            };
964            let should_scorers: Vec<Box<dyn Scorer + '_>> = match should_scorer {
965                Some(scorer) => vec![scorer],
966                None => {
967                    let mut scorers = Vec::with_capacity(should.len());
968                    for query in should {
969                        scorers.push(query.$scorer_fn(
970                            reader,
971                            limit,
972                            should_options.clone(),
973                        ) $(. $aw)* ?);
974                    }
975                    scorers
976                }
977            };
978
979            if must_verifiers.is_empty() {
980                let should_scorer = build_should_scorer(should_scorers);
981                log::debug!(
982                    "BooleanQuery planner: PredicatedScorer {} preds + {} must_not_v, \
983                     SHOULD size_hint={}, combined={}",
984                    predicates.len(), must_not_verifiers.len(),
985                    should_scorer.size_hint(), combined_should
986                );
987                return Ok(Box::new(super::PredicatedScorer::new(
988                    should_scorer, predicates, Vec::new(), must_not_verifiers,
989                )));
990            }
991
992            // Scoring MUST clauses drive the conjunction; SHOULD is optional.
993            log::debug!(
994                "BooleanQuery planner: required-clause BooleanScorer {} must + {} should, \
995                 {} preds + {} must_not_v",
996                must_verifiers.len(), should_scorers.len(),
997                predicates.len(), must_not_verifiers.len()
998            );
999            let mut driver = BooleanScorer {
1000                must: must_verifiers,
1001                should: should_scorers,
1002                must_not: Vec::new(),
1003                current_doc: 0,
1004        lead: 0,
1005        doc_limit: reader.num_docs(),
1006            };
1007            driver.initialize();
1008            return Ok(Box::new(super::PredicatedScorer::new(
1009                Box::new(driver),
1010                predicates,
1011                Vec::new(),
1012                must_not_verifiers,
1013            )));
1014        }
1015
1016        }
1017
1018        // ── 4. Standard BooleanScorer fallback ───────────────────────────
1019        if scorer_options.physical_text_field.is_none() {
1020            super::planner::push_down_text_predicates(
1021                must, should, must_not, reader, &mut scorer_options,
1022            )?;
1023        }
1024        if scorer_options.stop_if_expired()
1025            || scorer_options.eligibility.as_ref()
1026                .is_some_and(|bits| bits.next_set_bit(0).is_none()) {
1027            return Ok(Box::new(EmptyScorer) as Box<dyn Scorer + '_>);
1028        }
1029        let mut must_scorers = Vec::with_capacity(must.len());
1030        // A child top-k is not a safe candidate set for a summed parent:
1031        // a document outside every child heap may still have the best total.
1032        let child_options = if scorer_options.complete_text_matches
1033            || should.len() > 1
1034            || !should.is_empty() && !must.is_empty()
1035            || must.iter().filter(|query| query.as_doc_predicate(reader).is_none()).count() > 1
1036            || must_not.iter().any(|query| query.as_doc_predicate(reader).is_none())
1037        {
1038            scorer_options.for_required_clause()
1039        } else {
1040            scorer_options.without_threshold()
1041        };
1042        if must.is_empty() && should.is_empty() && !must_not.is_empty() {
1043            must_scorers.push(Box::new(super::AllDocSet::new(reader.num_docs()))
1044                as Box<dyn Scorer + '_>);
1045        }
1046        for q in must {
1047            must_scorers.push(q.$scorer_fn(
1048                reader, limit, child_options.clone()
1049            ) $(. $aw)* ?);
1050        }
1051        let mut should_scorers = Vec::with_capacity(should.len());
1052        for q in should {
1053            should_scorers.push(q.$scorer_fn(
1054                reader, limit, child_options.clone()
1055            ) $(. $aw)* ?);
1056        }
1057        let mut must_not_scorers = Vec::with_capacity(must_not.len());
1058        for q in must_not {
1059            must_not_scorers.push(q.$scorer_fn(
1060                reader, limit, scorer_options.for_required_clause()
1061            ) $(. $aw)* ?);
1062        }
1063        let mut scorer = BooleanScorer {
1064            must: must_scorers,
1065            should: should_scorers,
1066            must_not: must_not_scorers,
1067            current_doc: 0,
1068        lead: 0,
1069        doc_limit: reader.num_docs(),
1070        };
1071        scorer.initialize();
1072        Ok(Box::new(scorer) as Box<dyn Scorer + '_>)
1073    }};
1074}
1075
1076impl Query for BooleanQuery {
1077    fn physical_text_field(&self, reader: &SegmentReader, complete: bool) -> Option<crate::Field> {
1078        if self.proximity.is_some() || self.text_heap_factor != 1.0 || self.max_terms != 0 {
1079            return None;
1080        }
1081        // Preserve the existing ranked union executor and its stable-ID heap.
1082        if !complete
1083            && self.must.is_empty()
1084            && self.must_not.is_empty()
1085            && self
1086                .should
1087                .iter()
1088                .all(|q| matches!(q.decompose(), super::QueryDecomposition::TextTerm(_)))
1089        {
1090            return None;
1091        }
1092        let mut clauses = self.must.iter().chain(&self.should).chain(&self.must_not);
1093        let field = clauses.next()?.physical_text_field(reader, true)?;
1094        clauses
1095            .all(|q| q.physical_text_field(reader, true) == Some(field))
1096            .then_some(field)
1097    }
1098    fn candidate_query(&self) -> crate::Result<crate::query::CandidateQuery> {
1099        if !self.must.is_empty() || !self.must_not.is_empty() || self.proximity.is_some() {
1100            return Err(crate::Error::Query("L1 scoring branches support SHOULD composition; move required/excluded constraints into fusion.filters and use explicit phrase branches for proximity".into()));
1101        }
1102        if let super::QueryDecomposition::SparseTerms(infos) = self.decompose() {
1103            return super::CandidateQuery::from_decomposition(
1104                super::QueryDecomposition::SparseTerms(infos),
1105            );
1106        }
1107        super::CandidateQuery::sum(self.should.iter().map(|query| query.candidate_query()))
1108    }
1109
1110    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
1111        self.scorer_with_options(reader, limit, super::ScorerOptions::with_positions())
1112    }
1113
1114    fn scorer_with_options<'a>(
1115        &self,
1116        reader: &'a SegmentReader,
1117        limit: usize,
1118        options: super::ScorerOptions,
1119    ) -> ScorerFuture<'a> {
1120        let must = self.must.clone();
1121        let should = self.should.clone();
1122        let must_not = self.must_not.clone();
1123        let global_stats = self
1124            .global_stats
1125            .clone()
1126            .or_else(|| options.global_stats.clone());
1127        let proximity = self.proximity;
1128        let text_tuning = (self.text_heap_factor, self.max_terms);
1129        Box::pin(async move {
1130            boolean_plan!(
1131                must,
1132                should,
1133                must_not,
1134                global_stats.as_ref(),
1135                proximity,
1136                text_tuning,
1137                reader,
1138                limit,
1139                options,
1140                scorer_with_options,
1141                get_postings,
1142                execute,
1143                await
1144            )
1145        })
1146    }
1147
1148    #[cfg(feature = "sync")]
1149    fn scorer_sync<'a>(
1150        &self,
1151        reader: &'a SegmentReader,
1152        limit: usize,
1153    ) -> crate::Result<Box<dyn Scorer + 'a>> {
1154        self.scorer_sync_with_options(reader, limit, super::ScorerOptions::with_positions())
1155    }
1156
1157    #[cfg(feature = "sync")]
1158    fn scorer_sync_with_options<'a>(
1159        &self,
1160        reader: &'a SegmentReader,
1161        limit: usize,
1162        options: super::ScorerOptions,
1163    ) -> crate::Result<Box<dyn Scorer + 'a>> {
1164        let global_stats = self
1165            .global_stats
1166            .clone()
1167            .or_else(|| options.global_stats.clone());
1168        boolean_plan!(
1169            self.must,
1170            self.should,
1171            self.must_not,
1172            global_stats.as_ref(),
1173            self.proximity,
1174            (self.text_heap_factor, self.max_terms),
1175            reader,
1176            limit,
1177            options,
1178            scorer_sync_with_options,
1179            get_postings_sync,
1180            execute_sync
1181        )
1182    }
1183
1184    fn text_terms(&self, out: &mut Vec<(crate::dsl::Field, Vec<u8>)>) {
1185        for clause in self.must.iter().chain(&self.should).chain(&self.must_not) {
1186            clause.text_terms(out);
1187        }
1188    }
1189
1190    fn decompose(&self) -> super::QueryDecomposition {
1191        if !self.must.is_empty() || !self.must_not.is_empty() {
1192            return super::QueryDecomposition::Opaque;
1193        }
1194        self.sparse_decomposition()
1195    }
1196
1197    fn count_equivalent_term(&self) -> Option<super::TermQueryInfo> {
1198        if self.must.len() == 1
1199            && self.must_not.is_empty()
1200            && self.proximity.is_none()
1201            && self.text_heap_factor == 1.0
1202            && self.max_terms == 0
1203            && self
1204                .should
1205                .iter()
1206                .all(|query| matches!(query.decompose(), super::QueryDecomposition::TextTerm(_)))
1207        {
1208            self.must[0].count_equivalent_term()
1209        } else {
1210            None
1211        }
1212    }
1213
1214    fn supports_ranked_conjunction_count(&self) -> bool {
1215        self.must.len() >= 2 && self.should.is_empty() && self.must_not.is_empty()
1216            && self.proximity.is_none() && self.text_heap_factor == 1.0 && self.max_terms == 0
1217            && self.must.iter().all(|query| matches!(query.decompose(),
1218                super::QueryDecomposition::TextTerm(info) if info.weight == 1.0 && info.global_stats.is_none()))
1219    }
1220
1221    fn ranked_count_equivalent_term(&self) -> Option<super::TermQueryInfo> {
1222        let count = self.count_equivalent_term()?;
1223        let super::QueryDecomposition::TextTerm(required) = self.must[0].decompose() else {
1224            return None;
1225        };
1226        (required.weight.is_finite()
1227            && required.weight > 0.0
1228            && self.should.iter().all(|query| {
1229                matches!(query.decompose(), super::QueryDecomposition::TextTerm(info)
1230                    if info.field == required.field && info.weight.is_finite() && info.weight > 0.0)
1231            }))
1232        .then_some(count)
1233    }
1234
1235    fn sparse_decomposition(&self) -> super::QueryDecomposition {
1236        // LSP/0 selection depends only on the sparse scoring clauses. Pure
1237        // filters may remove documents but cannot increase their score, so a
1238        // query-global superblock plan remains valid and must be shared across
1239        // segments for filtered sparse queries too. A scoring MUST clause can
1240        // change final ordering, therefore keep that shape opaque.
1241        if self.should.is_empty() || self.must.iter().any(|query| !query.is_filter()) {
1242            return super::QueryDecomposition::Opaque;
1243        }
1244        extract_all_sparse_infos(&self.should)
1245            .map(super::QueryDecomposition::SparseTerms)
1246            .unwrap_or(super::QueryDecomposition::Opaque)
1247    }
1248
1249    fn should_children(&self) -> Option<&[Arc<dyn Query>]> {
1250        if self.must.is_empty()
1251            && self.must_not.is_empty()
1252            && !self.should.is_empty()
1253            && self.proximity.is_none()
1254            && self.global_stats.is_none()
1255            && self.text_heap_factor == 1.0
1256            && self.max_terms == 0
1257        {
1258            Some(&self.should)
1259        } else {
1260            None
1261        }
1262    }
1263
1264    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
1265        self.as_doc_bitset_with_options(reader, &super::ScorerOptions::default())
1266    }
1267
1268    fn as_doc_bitset_with_options(
1269        &self,
1270        reader: &SegmentReader,
1271        options: &super::ScorerOptions,
1272    ) -> Option<super::DocBitset> {
1273        if options.stop_if_expired() {
1274            return None;
1275        }
1276        if self.must.is_empty() && self.should.is_empty() && self.must_not.is_empty() {
1277            return None;
1278        }
1279
1280        let num_docs = reader.num_docs();
1281
1282        // MUST clauses: intersect bitsets (AND)
1283        let mut result = (self.must.is_empty() && self.should.is_empty())
1284            .then(|| super::DocBitset::all(num_docs));
1285        for q in &self.must {
1286            let bs = options.doc_bitset(q.as_ref(), reader)?;
1287            match result {
1288                None => result = Some(bs),
1289                Some(ref mut acc) => acc.intersect_with(&bs),
1290            }
1291        }
1292
1293        // SHOULD clauses: union bitsets (OR), then intersect with MUST result
1294        if !self.should.is_empty() {
1295            let mut should_union = super::DocBitset::new(num_docs);
1296            for q in &self.should {
1297                let bs = options.doc_bitset(q.as_ref(), reader)?;
1298                should_union.union_with(&bs);
1299            }
1300            match result {
1301                None => result = Some(should_union),
1302                Some(ref mut acc) => {
1303                    // When MUST clauses exist, SHOULD is optional (doesn't filter).
1304                    // When no MUST clauses, at least one SHOULD must match.
1305                    if self.must.is_empty() {
1306                        *acc = should_union;
1307                    }
1308                }
1309            }
1310        }
1311
1312        // MUST_NOT clauses: subtract bitsets (ANDNOT)
1313        if let Some(ref mut acc) = result {
1314            for q in &self.must_not {
1315                {
1316                    let bs = options.doc_bitset(q.as_ref(), reader)?;
1317                    acc.subtract(&bs);
1318                }
1319            }
1320        }
1321
1322        if options.stop_if_expired() {
1323            None
1324        } else {
1325            result
1326        }
1327    }
1328
1329    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
1330        // Need at least some clauses
1331        if self.must.is_empty() && self.should.is_empty() && self.must_not.is_empty() {
1332            return None;
1333        }
1334
1335        // Try converting all clauses to predicates; bail if any child can't
1336        let must_preds: Vec<_> = self
1337            .must
1338            .iter()
1339            .map(|q| q.as_doc_predicate(reader))
1340            .collect::<Option<Vec<_>>>()?;
1341        let should_preds: Vec<_> = self
1342            .should
1343            .iter()
1344            .map(|q| q.as_doc_predicate(reader))
1345            .collect::<Option<Vec<_>>>()?;
1346        let must_not_preds: Vec<_> = self
1347            .must_not
1348            .iter()
1349            .map(|q| q.as_doc_predicate(reader))
1350            .collect::<Option<Vec<_>>>()?;
1351
1352        let has_must = !must_preds.is_empty();
1353
1354        Some(Box::new(move |doc_id| {
1355            // All MUST predicates must pass
1356            if !must_preds.iter().all(|p| p(doc_id)) {
1357                return false;
1358            }
1359            // When there are no MUST clauses, at least one SHOULD must pass
1360            if !has_must && !should_preds.is_empty() && !should_preds.iter().any(|p| p(doc_id)) {
1361                return false;
1362            }
1363            // No MUST_NOT predicate should pass
1364            must_not_preds.iter().all(|p| !p(doc_id))
1365        }))
1366    }
1367
1368    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
1369        let must = self.must.clone();
1370        let should = self.should.clone();
1371        let has_exclusions = !self.must_not.is_empty();
1372
1373        Box::pin(async move {
1374            if !must.is_empty() {
1375                let mut estimates = Vec::with_capacity(must.len());
1376                for q in &must {
1377                    estimates.push(q.count_estimate(reader).await?);
1378                }
1379                estimates
1380                    .into_iter()
1381                    .min()
1382                    .ok_or_else(|| crate::Error::Corruption("Empty must clause".to_string()))
1383            } else if !should.is_empty() {
1384                let mut sum = 0u32;
1385                for q in &should {
1386                    sum = sum.saturating_add(q.count_estimate(reader).await?);
1387                }
1388                Ok(sum)
1389            } else if has_exclusions {
1390                Ok(reader.num_docs())
1391            } else {
1392                Ok(0)
1393            }
1394        })
1395    }
1396}
1397
1398pub(super) struct BooleanScorer<'a> {
1399    must: Vec<Box<dyn Scorer + 'a>>,
1400    should: Vec<Box<dyn Scorer + 'a>>,
1401    must_not: Vec<Box<dyn Scorer + 'a>>,
1402    current_doc: DocId,
1403    /// Most selective required cursor; preserve score summation order.
1404    lead: usize,
1405    /// Segment document space, used only to estimate window setup cost.
1406    doc_limit: u32,
1407}
1408
1409impl<'a> BooleanScorer<'a> {
1410    pub(super) fn disjunction(should: Vec<Box<dyn Scorer + 'a>>) -> Self {
1411        let mut scorer = Self {
1412            must: Vec::new(),
1413            should,
1414            must_not: Vec::new(),
1415            current_doc: 0,
1416            lead: 0,
1417            doc_limit: 0,
1418        };
1419        scorer.initialize();
1420        scorer
1421    }
1422
1423    fn initialize(&mut self) {
1424        self.lead = self
1425            .must
1426            .iter()
1427            .enumerate()
1428            .min_by_key(|(_, scorer)| match scorer.size_hint() {
1429                0 => u32::MAX,
1430                cost => cost,
1431            })
1432            .map_or(0, |(index, _)| index);
1433        self.current_doc = self.find_next_match();
1434    }
1435
1436    fn find_next_match(&mut self) -> DocId {
1437        if self.must.is_empty() && self.should.is_empty() {
1438            return TERMINATED;
1439        }
1440
1441        loop {
1442            let candidate = if !self.must.is_empty() {
1443                let mut candidate = self.must[self.lead].doc();
1444                'align: loop {
1445                    if candidate == TERMINATED {
1446                        return TERMINATED;
1447                    }
1448                    for index in 0..self.must.len() {
1449                        if index == self.lead {
1450                            continue;
1451                        }
1452                        let doc = self.must[index].seek_candidate(candidate);
1453                        if doc > candidate {
1454                            // No intersection exists before the rejecting
1455                            // cursor's next candidate. Advance the selected
1456                            // driver without revisiting the rejected prefix.
1457                            candidate = self.must[self.lead].seek_candidate(doc);
1458                            continue 'align;
1459                        }
1460                    }
1461                    break candidate;
1462                }
1463            } else {
1464                self.should
1465                    .iter()
1466                    .map(|s| s.doc())
1467                    .filter(|&d| d != TERMINATED)
1468                    .min()
1469                    .unwrap_or(TERMINATED)
1470            };
1471
1472            if candidate == TERMINATED {
1473                return TERMINATED;
1474            }
1475
1476            if !self
1477                .must
1478                .iter_mut()
1479                .all(|scorer| scorer.confirm_candidate())
1480            {
1481                self.must[self.lead].advance_candidate();
1482                continue;
1483            }
1484
1485            let excluded = self.must_not.iter_mut().any(|scorer| {
1486                let doc = scorer.seek(candidate);
1487                doc == candidate
1488            });
1489
1490            if !excluded {
1491                // Seek SHOULD scorers to candidate so score() can see their contributions
1492                for scorer in &mut self.should {
1493                    scorer.seek(candidate);
1494                }
1495                self.current_doc = candidate;
1496                return candidate;
1497            }
1498
1499            // Advance past excluded candidate
1500            if !self.must.is_empty() {
1501                self.must[self.lead].advance_candidate();
1502            } else {
1503                // For SHOULD-only: seek all scorers past the excluded candidate
1504                for scorer in &mut self.should {
1505                    if scorer.doc() <= candidate && scorer.doc() != TERMINATED {
1506                        scorer.seek(candidate + 1);
1507                    }
1508                }
1509            }
1510        }
1511    }
1512}
1513
1514impl super::docset::DocSet for BooleanScorer<'_> {
1515    fn supports_doc_batches(&self) -> bool {
1516        self.must.len() > 1
1517            && self.should.is_empty()
1518            && self.must_not.is_empty()
1519            && self.must.iter().all(|child| child.supports_doc_batches())
1520    }
1521
1522    fn fill_doc_batch(&mut self, docs: &mut super::docset::DocBatch) -> usize {
1523        if !self.supports_doc_batches() {
1524            return super::docset::fill_batch(self, docs);
1525        }
1526        if self.current_doc == TERMINATED {
1527            return 0;
1528        }
1529        let mut count = self.must[self.lead].fill_doc_batch(docs);
1530        for (index, child) in self.must.iter_mut().enumerate() {
1531            if index != self.lead {
1532                count = child.retain_doc_batch(docs, count);
1533                if count == 0 {
1534                    break;
1535                }
1536            }
1537        }
1538        self.current_doc = self.find_next_match();
1539        count
1540    }
1541
1542    fn supports_doc_windows(&self) -> bool {
1543        self.must_not.is_empty()
1544            && if self.must.is_empty() {
1545                self.should.iter().all(|child| child.supports_doc_windows())
1546            } else {
1547                self.should.is_empty()
1548                    && u64::from(self.must[self.lead].size_hint())
1549                        * u64::from(super::docset::DOC_WINDOW_SIZE)
1550                        >= u64::from(self.doc_limit) * super::docset::DOC_WINDOW_WORDS as u64
1551                    && self.must.iter().all(|child| child.supports_doc_windows())
1552            }
1553    }
1554
1555    fn fill_doc_window(&mut self, base: DocId, bits: &mut super::docset::DocWindow) {
1556        if !self.supports_doc_windows() {
1557            return super::docset::fill_window(self, base, bits);
1558        }
1559        bits.fill(0);
1560        if self.current_doc == TERMINATED {
1561            return;
1562        }
1563        let mut child_bits = [0; super::docset::DOC_WINDOW_WORDS];
1564        if self.must.is_empty() {
1565            for child in &mut self.should {
1566                child.fill_doc_window(base, &mut child_bits);
1567                for (out, child_word) in bits.iter_mut().zip(child_bits) {
1568                    *out |= child_word;
1569                }
1570            }
1571        } else {
1572            self.must[self.lead].fill_doc_window(base, bits);
1573            for (index, child) in self.must.iter_mut().enumerate() {
1574                if index == self.lead {
1575                    continue;
1576                }
1577                let candidates: u32 = bits.iter().map(|word| word.count_ones()).sum();
1578                if candidates == 0 {
1579                    break;
1580                }
1581                // Dense masks amortize a full child batch over bitmap words;
1582                // selective masks probe only the surviving candidates.
1583                if candidates > super::docset::DOC_WINDOW_WORDS as u32 {
1584                    child.fill_doc_window(base, &mut child_bits);
1585                    for (out, child_word) in bits.iter_mut().zip(child_bits) {
1586                        *out &= child_word;
1587                    }
1588                } else {
1589                    for (word_index, word) in bits.iter_mut().enumerate() {
1590                        let mut remaining = *word;
1591                        while remaining != 0 {
1592                            let bit = remaining.trailing_zeros();
1593                            let doc = base + word_index as u32 * 64 + bit;
1594                            if child.seek(doc) != doc {
1595                                *word &= !(1u64 << bit);
1596                            }
1597                            remaining &= remaining - 1;
1598                        }
1599                    }
1600                }
1601            }
1602        }
1603        self.current_doc = self.find_next_match();
1604    }
1605
1606    fn doc(&self) -> DocId {
1607        self.current_doc
1608    }
1609
1610    fn advance(&mut self) -> DocId {
1611        if !self.must.is_empty() {
1612            self.must[self.lead].advance_candidate();
1613        } else {
1614            for scorer in &mut self.should {
1615                if scorer.doc() == self.current_doc {
1616                    scorer.advance();
1617                }
1618            }
1619        }
1620
1621        self.current_doc = self.find_next_match();
1622        self.current_doc
1623    }
1624
1625    fn seek(&mut self, target: DocId) -> DocId {
1626        if self.must.is_empty() {
1627            for scorer in &mut self.should {
1628                scorer.seek(target);
1629            }
1630        } else {
1631            self.must[self.lead].seek_candidate(target);
1632        }
1633
1634        self.current_doc = self.find_next_match();
1635        self.current_doc
1636    }
1637
1638    fn size_hint(&self) -> u32 {
1639        if !self.must.is_empty() {
1640            self.must.iter().map(|s| s.size_hint()).min().unwrap_or(0)
1641        } else {
1642            self.should
1643                .iter()
1644                .fold(0u32, |total, s| total.saturating_add(s.size_hint()))
1645        }
1646    }
1647}
1648
1649impl Scorer for BooleanScorer<'_> {
1650    fn supports_score_batches(&self) -> bool {
1651        !self.must.is_empty()
1652            && (self.must.len() > 1 || !self.must_not.is_empty())
1653            && self.should.is_empty()
1654            && self.must[self.lead].size_hint() > super::docset::DOC_BATCH_SIZE as u32
1655            && self.must.iter().all(|child| child.supports_score_batches())
1656    }
1657
1658    fn fill_score_batch(
1659        &mut self,
1660        docs: &mut super::docset::DocBatch,
1661        scores: &mut super::ScoreBatch,
1662    ) -> usize {
1663        if !self.supports_score_batches() {
1664            return super::traits::fill_score_batch_scalar(self, docs, scores);
1665        }
1666        if self.current_doc == TERMINATED {
1667            return 0;
1668        }
1669        let mut lead_scores = [0.0; super::docset::DOC_BATCH_SIZE];
1670        let mut count = self.must[self.lead].fill_score_batch(docs, &mut lead_scores);
1671        let mut origins: [u8; super::docset::DOC_BATCH_SIZE] = std::array::from_fn(|i| i as u8);
1672        let mut values = [0.0; super::docset::DOC_BATCH_SIZE];
1673        let mut matches = [0; 2];
1674        scores[..count].fill(0.0);
1675        for (index, child) in self.must.iter_mut().enumerate() {
1676            if index == self.lead {
1677                for row in 0..count {
1678                    scores[row] += lead_scores[origins[row] as usize];
1679                }
1680                continue;
1681            }
1682            child.score_batch_matches(docs, count, &mut values, &mut matches);
1683            let mut kept = 0;
1684            for row in 0..count {
1685                if matches[row / 64] & (1 << (row % 64)) != 0 {
1686                    docs[kept] = docs[row];
1687                    scores[kept] = scores[row] + values[row];
1688                    origins[kept] = origins[row];
1689                    kept += 1;
1690                }
1691            }
1692            count = kept;
1693            if count == 0 {
1694                break;
1695            }
1696        }
1697        if !self.must_not.is_empty() {
1698            let mut kept = 0;
1699            for row in 0..count {
1700                let doc = docs[row];
1701                if self.must_not.iter_mut().all(|child| child.seek(doc) != doc) {
1702                    docs[kept] = doc;
1703                    scores[kept] = scores[row];
1704                    kept += 1;
1705                }
1706            }
1707            count = kept;
1708        }
1709        self.current_doc = self.find_next_match();
1710        count
1711    }
1712
1713    fn supports_filtered_windows(&self) -> bool {
1714        super::docset::DocSet::supports_doc_windows(self)
1715            && (self.must.len() > 1 || self.should.len() > 1)
1716    }
1717
1718    fn supports_score_windows(&self) -> bool {
1719        self.must.is_empty()
1720            && self.should.len() > 1
1721            && super::docset::DocSet::supports_doc_windows(self)
1722    }
1723
1724    fn fill_score_window(
1725        &mut self,
1726        base: DocId,
1727        scores: &mut [Score; super::docset::DOC_WINDOW_SIZE as usize],
1728        bits: &mut super::docset::DocWindow,
1729    ) {
1730        scores.fill(0.0);
1731        bits.fill(0);
1732        if !self.supports_score_windows() {
1733            self.accumulate_score_window(base, scores, bits);
1734            return;
1735        }
1736        if self.current_doc == TERMINATED {
1737            return;
1738        }
1739        // Preserve the scalar scorer's child order, including the complete
1740        // score of any nested child. Never distribute a nested floating sum.
1741        for child in &mut self.should {
1742            child.accumulate_score_window(base, scores, bits);
1743        }
1744        self.current_doc = self.find_next_match();
1745    }
1746
1747    fn score(&self) -> Score {
1748        let mut total = 0.0;
1749
1750        for scorer in &self.must {
1751            if scorer.doc() == self.current_doc {
1752                total += scorer.score();
1753            }
1754        }
1755
1756        for scorer in &self.should {
1757            if scorer.doc() == self.current_doc {
1758                total += scorer.score();
1759            }
1760        }
1761
1762        total
1763    }
1764
1765    fn matched_positions(&self) -> Option<super::MatchedPositions> {
1766        let mut all_positions: super::MatchedPositions = Vec::new();
1767
1768        for scorer in &self.must {
1769            if scorer.doc() == self.current_doc
1770                && let Some(positions) = scorer.matched_positions()
1771            {
1772                all_positions.extend(positions);
1773            }
1774        }
1775
1776        for scorer in &self.should {
1777            if scorer.doc() == self.current_doc
1778                && let Some(positions) = scorer.matched_positions()
1779            {
1780                all_positions.extend(positions);
1781            }
1782        }
1783
1784        if all_positions.is_empty() {
1785            None
1786        } else {
1787            Some(merge_matched_positions(all_positions))
1788        }
1789    }
1790}
1791
1792/// Coalesce the position lists that several clauses reported for one field.
1793///
1794/// Two term clauses on the same chunked field each report the chunk ordinal
1795/// they matched; the union must present one entry per chunk whose score is
1796/// the sum of the clause contributions (the chunk's BM25 score), not the same
1797/// ordinal twice. Distinct positions are left untouched, so token positions of
1798/// `positions`-mode fields keep their per-term scores.
1799pub(super) fn merge_matched_positions(
1800    positions: super::MatchedPositions,
1801) -> super::MatchedPositions {
1802    if positions.len() < 2 {
1803        return positions;
1804    }
1805    let mut merged: super::MatchedPositions = Vec::with_capacity(positions.len());
1806    for (field_id, scored) in positions {
1807        match merged
1808            .iter_mut()
1809            .find(|(existing, _)| *existing == field_id)
1810        {
1811            Some((_, existing)) => existing.extend(scored),
1812            None => merged.push((field_id, scored)),
1813        }
1814    }
1815    for (_, scored) in &mut merged {
1816        if scored.len() < 2 {
1817            continue;
1818        }
1819        scored.sort_by_key(|sp| sp.position);
1820        let mut write = 0usize;
1821        for read in 1..scored.len() {
1822            if scored[read].position == scored[write].position {
1823                scored[write].score += scored[read].score;
1824            } else {
1825                write += 1;
1826                scored[write] = scored[read];
1827            }
1828        }
1829        scored.truncate(write + 1);
1830    }
1831    merged
1832}
1833
1834/// Optional SHOULD terms for the ranked text window executor: all plain text
1835/// terms on `field`, or an empty list when there are none.
1836fn ranked_optional_terms(
1837    should: &[Arc<dyn Query>],
1838    field: crate::Field,
1839    reader: &SegmentReader,
1840    global_stats: Option<&Arc<GlobalStats>>,
1841) -> Option<Vec<super::TermQueryInfo>> {
1842    if should.is_empty() {
1843        return Some(Vec::new());
1844    }
1845    let (optional, optional_field, _, _) = prepare_text_maxscore(should, reader, global_stats)?;
1846    (optional_field == field).then_some(optional)
1847}
1848
1849/// Configure the shared text window executor for ranked term queries whose
1850/// first `required_count` cursors are semantic MUST terms. All-required
1851/// queries with at least two cursors use the conjunction driver.
1852fn ranked_text_executor<'a>(
1853    cursors: Vec<super::TermCursor<'a>>,
1854    required_count: usize,
1855    limit: usize,
1856    reader: &'a SegmentReader,
1857    field: crate::Field,
1858    options: &super::ScorerOptions,
1859) -> super::MaxScoreExecutor<'a> {
1860    let all_required = required_count == cursors.len() && cursors.len() >= 2;
1861    let executor = super::MaxScoreExecutor::new(cursors, limit, 1.0);
1862    let mut executor = if all_required {
1863        executor.require_all_terms()
1864    } else {
1865        executor.require_prefix_terms(required_count)
1866    }
1867    .with_metric_labels(
1868        reader.schema().index_label(),
1869        reader.schema().get_field_name(field).unwrap_or("?"),
1870    )
1871    .with_budget(options.shared_threshold.clone());
1872    if options.physical_text_field == Some(field) {
1873        executor =
1874            executor.with_document_map(reader.chunk_map(field).expect("admitted document map"));
1875    }
1876    if let Some(predicate) = eligibility_predicate(options) {
1877        executor = executor.with_predicate(predicate);
1878    }
1879    if options.initial_threshold > 0.0 {
1880        executor.seed_threshold(options.initial_threshold);
1881    }
1882    executor
1883}
1884
1885fn eligibility_predicate(options: &super::ScorerOptions) -> Option<super::DocPredicate<'static>> {
1886    options.eligibility.as_ref().map(|filter| {
1887        let filter = filter.clone();
1888        Box::new(move |doc| filter.contains(doc)) as super::DocPredicate<'static>
1889    })
1890}
1891
1892#[cfg(test)]
1893mod tests {
1894    use super::*;
1895    use crate::dsl::Field;
1896    use crate::query::{DocSet, QueryDecomposition, TermQuery};
1897
1898    #[test]
1899    fn ranked_count_hints_require_an_exact_compatible_text_plan() {
1900        struct CountOnly(TermQuery);
1901        impl std::fmt::Display for CountOnly {
1902            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1903                self.0.fmt(f)
1904            }
1905        }
1906        impl Query for CountOnly {
1907            fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
1908                self.0.scorer(reader, limit)
1909            }
1910            fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
1911                self.0.count_estimate(reader)
1912            }
1913            fn count_equivalent_term(&self) -> Option<super::super::TermQueryInfo> {
1914                self.0.count_equivalent_term()
1915            }
1916        }
1917        let field = Field(0);
1918        let compatible = BooleanQuery::new()
1919            .must(TermQuery::text(field, "required"))
1920            .should(TermQuery::text(field, "optional"));
1921        assert!(compatible.ranked_count_equivalent_term().is_some());
1922        assert!(
1923            Arc::new(compatible.clone())
1924                .ranked_count_equivalent_term()
1925                .is_some()
1926        );
1927        for query in [
1928            compatible
1929                .clone()
1930                .must_not(TermQuery::text(field, "excluded")),
1931            compatible.clone().must(TermQuery::text(field, "second")),
1932            compatible.clone().with_text_heap_factor(0.5),
1933            compatible.clone().with_max_terms(1),
1934            compatible
1935                .clone()
1936                .should(TermQuery::text(Field(1), "other-field")),
1937            compatible.should(super::super::BoostQuery::new(
1938                TermQuery::text(field, "negative"),
1939                -1.0,
1940            )),
1941            BooleanQuery::new()
1942                .must(CountOnly(TermQuery::text(field, "opaque")))
1943                .should(TermQuery::text(field, "optional")),
1944        ] {
1945            assert!(query.ranked_count_equivalent_term().is_none());
1946        }
1947        let opaque = CountOnly(TermQuery::text(field, "opaque"));
1948        assert!(opaque.count_equivalent_term().is_some());
1949        assert!(opaque.ranked_count_equivalent_term().is_none());
1950    }
1951
1952    #[test]
1953    fn compact_score_batches_preserve_query_order_and_nested_scores() {
1954        use crate::query::docset::{DOC_BATCH_SIZE, DocSet, SortedVecDocSet};
1955        struct ExactScore {
1956            docs: SortedVecDocSet,
1957            value: f32,
1958        }
1959        impl DocSet for ExactScore {
1960            fn doc(&self) -> u32 {
1961                self.docs.doc()
1962            }
1963            fn advance(&mut self) -> u32 {
1964                self.docs.advance()
1965            }
1966            fn seek(&mut self, target: u32) -> u32 {
1967                self.docs.seek(target)
1968            }
1969            fn size_hint(&self) -> u32 {
1970                self.docs.size_hint()
1971            }
1972        }
1973        impl Scorer for ExactScore {
1974            fn score(&self) -> f32 {
1975                self.value
1976            }
1977            fn supports_score_batches(&self) -> bool {
1978                true
1979            }
1980        }
1981        fn leaf(step: u32, value: f32) -> Box<dyn Scorer> {
1982            Box::new(ExactScore {
1983                docs: SortedVecDocSet::new(Arc::new(
1984                    (0..20000).filter(|d| d % step == 0).collect(),
1985                )),
1986                value,
1987            })
1988        }
1989        fn conjunction(children: Vec<Box<dyn Scorer>>) -> BooleanScorer<'static> {
1990            let mut result = BooleanScorer::disjunction(Vec::new());
1991            result.must = children;
1992            result.initialize();
1993            result
1994        }
1995        fn make(nested: bool, excluded: bool, single: bool) -> BooleanScorer<'static> {
1996            let middle: Box<dyn Scorer> = if nested {
1997                Box::new(conjunction(vec![leaf(7, 1.0e10), leaf(7, 1.0)]))
1998            } else {
1999                leaf(7, 1.0e10)
2000            };
2001            let mut scorer = if single {
2002                conjunction(vec![leaf(3, 1.0)])
2003            } else {
2004                conjunction(vec![leaf(3, -1.0e10), middle, leaf(11, 1.0)])
2005            };
2006            if excluded {
2007                scorer.must_not = vec![leaf(2, 500.0), leaf(13, 500.0)];
2008                scorer.initialize();
2009            }
2010            scorer
2011        }
2012        for (nested, excluded, single) in [
2013            (false, false, false),
2014            (true, false, false),
2015            (false, true, false),
2016            (true, true, false),
2017            (false, true, true),
2018        ] {
2019            let mut scalar = make(nested, excluded, single);
2020            let mut batched = make(nested, excluded, single);
2021            assert_eq!(batched.lead, if single { 0 } else { 2 });
2022            assert!(batched.supports_score_batches());
2023            let mut docs = [0; DOC_BATCH_SIZE];
2024            let mut scores = [0.0; DOC_BATCH_SIZE];
2025            assert_eq!(batched.seek(17), scalar.seek(17));
2026            while batched.doc() != TERMINATED {
2027                let count = batched.fill_score_batch(&mut docs, &mut scores);
2028                assert!(count > 0);
2029                for i in 0..count {
2030                    assert_eq!(docs[i], scalar.doc());
2031                    assert_eq!(scores[i].to_bits(), scalar.score().to_bits());
2032                    assert_eq!(
2033                        scores[i], 1.0,
2034                        "lead-first or distributed sums change this value"
2035                    );
2036                    scalar.advance();
2037                }
2038                assert_eq!(batched.doc(), scalar.doc());
2039                assert_eq!(batched.advance(), scalar.advance());
2040            }
2041            assert_eq!(scalar.doc(), TERMINATED);
2042            assert_eq!(batched.fill_score_batch(&mut docs, &mut scores), 0);
2043        }
2044    }
2045
2046    #[test]
2047    fn compact_boolean_batches_preserve_scalar_resume_and_nested_membership() {
2048        use crate::query::docset::{DOC_BATCH_SIZE, DocSet, SortedVecDocSet};
2049        struct ExactDocs(SortedVecDocSet);
2050        impl DocSet for ExactDocs {
2051            fn doc(&self) -> u32 {
2052                self.0.doc()
2053            }
2054            fn advance(&mut self) -> u32 {
2055                self.0.advance()
2056            }
2057            fn seek(&mut self, target: u32) -> u32 {
2058                self.0.seek(target)
2059            }
2060            fn size_hint(&self) -> u32 {
2061                self.0.size_hint()
2062            }
2063            fn supports_doc_batches(&self) -> bool {
2064                true
2065            }
2066        }
2067        impl Scorer for ExactDocs {
2068            fn score(&self) -> f32 {
2069                self.doc() as f32
2070            }
2071        }
2072        fn leaf(step: u32) -> Box<dyn Scorer> {
2073            Box::new(ExactDocs(SortedVecDocSet::new(Arc::new(
2074                (0..5000).filter(|doc| doc % step == 0).collect(),
2075            ))))
2076        }
2077        fn make(nested: bool) -> BooleanScorer<'static> {
2078            let mut scorer = BooleanScorer::disjunction(Vec::new());
2079            scorer.must = vec![leaf(3), leaf(7)];
2080            if nested {
2081                scorer.must.push(Box::new(make(false)));
2082            }
2083            scorer.doc_limit = 1_000_000;
2084            scorer.initialize();
2085            scorer
2086        }
2087        for nested in [false, true] {
2088            let mut scalar = make(nested);
2089            let mut batched = make(nested);
2090            assert!(batched.supports_doc_batches());
2091            assert_eq!(batched.seek(17), scalar.seek(17));
2092            let mut docs = [0; DOC_BATCH_SIZE];
2093            while batched.doc() != TERMINATED {
2094                assert_eq!(batched.doc(), scalar.doc());
2095                assert_eq!(batched.score().to_bits(), scalar.score().to_bits());
2096                let count = batched.fill_doc_batch(&mut docs);
2097                assert!(count > 0);
2098                for &doc in &docs[..count] {
2099                    assert_eq!(doc, scalar.doc());
2100                    scalar.advance();
2101                }
2102                assert_eq!(batched.doc(), scalar.doc());
2103                assert_eq!(batched.advance(), scalar.advance());
2104            }
2105            assert_eq!(scalar.doc(), TERMINATED);
2106            assert_eq!(batched.fill_doc_batch(&mut docs), 0);
2107        }
2108    }
2109
2110    #[test]
2111    fn score_windows_preserve_nested_sums_seek_and_exhaustion() {
2112        struct Scores {
2113            hits: Vec<(DocId, Score)>,
2114            index: usize,
2115        }
2116        impl DocSet for Scores {
2117            fn doc(&self) -> DocId {
2118                self.hits.get(self.index).map_or(TERMINATED, |hit| hit.0)
2119            }
2120            fn advance(&mut self) -> DocId {
2121                self.index = (self.index + 1).min(self.hits.len());
2122                self.doc()
2123            }
2124            fn size_hint(&self) -> u32 {
2125                self.hits.len() as u32
2126            }
2127            fn supports_doc_windows(&self) -> bool {
2128                true
2129            }
2130        }
2131        impl Scorer for Scores {
2132            fn score(&self) -> Score {
2133                self.hits[self.index].1
2134            }
2135        }
2136        fn child(seed: u32) -> Box<dyn Scorer> {
2137            let hits = (0..16_400)
2138                .chain([u32::MAX - 4097, u32::MAX - 2])
2139                .filter(|doc| (doc % (seed + 2)) != 1)
2140                .map(|doc| {
2141                    (
2142                        doc,
2143                        match seed {
2144                            0 => 16_777_216.0,
2145                            1 => 1.0,
2146                            2 => -16_777_216.0,
2147                            _ => -0.0,
2148                        },
2149                    )
2150                })
2151                .collect();
2152            Box::new(Scores { hits, index: 0 })
2153        }
2154        fn build(shape: u8) -> BooleanScorer<'static> {
2155            let children = if shape == 2 {
2156                let mut conjunction = BooleanScorer {
2157                    must: vec![child(1), child(2)],
2158                    should: Vec::new(),
2159                    must_not: Vec::new(),
2160                    current_doc: 0,
2161                    lead: 0,
2162                    doc_limit: 0,
2163                };
2164                conjunction.initialize();
2165                vec![child(0), Box::new(conjunction), child(3)]
2166            } else if shape == 1 {
2167                vec![
2168                    child(0),
2169                    Box::new(BooleanScorer::disjunction(vec![child(1), child(2)])),
2170                    child(3),
2171                ]
2172            } else {
2173                (0..4).map(child).collect()
2174            };
2175            BooleanScorer::disjunction(children)
2176        }
2177        for shape in 0..3 {
2178            for start in [0, 17, 4095, 8193, u32::MAX - 4098, TERMINATED] {
2179                let mut scalar = build(shape);
2180                let mut batched = build(shape);
2181                scalar.seek(start);
2182                batched.seek(start);
2183                assert!(batched.supports_score_windows());
2184                let mut scores =
2185                    Box::new([f32::NAN; super::super::docset::DOC_WINDOW_SIZE as usize]);
2186                let mut bits = [u64::MAX; super::super::docset::DOC_WINDOW_WORDS];
2187                while batched.doc() != TERMINATED {
2188                    let base = batched.doc();
2189                    let end = base.saturating_add(super::super::docset::DOC_WINDOW_SIZE);
2190                    batched.fill_score_window(base, &mut scores, &mut bits);
2191                    for (index, &word) in bits.iter().enumerate() {
2192                        let mut remaining = word;
2193                        while remaining != 0 {
2194                            let slot = index * 64 + remaining.trailing_zeros() as usize;
2195                            assert_eq!(base + slot as u32, scalar.doc());
2196                            assert_eq!(
2197                                scores[slot].to_bits(),
2198                                scalar.score().to_bits(),
2199                                "shape={shape} start={start} doc={}",
2200                                scalar.doc()
2201                            );
2202                            scalar.advance();
2203                            remaining &= remaining - 1;
2204                        }
2205                    }
2206                    assert!(scalar.doc() >= end);
2207                    assert_eq!(scalar.doc(), batched.doc());
2208                    if scalar.doc() != TERMINATED {
2209                        assert_eq!(scalar.score().to_bits(), batched.score().to_bits());
2210                    }
2211                }
2212                batched.fill_score_window(TERMINATED, &mut scores, &mut bits);
2213                assert!(bits.iter().all(|word| *word == 0));
2214                assert_eq!(batched.advance(), TERMINATED);
2215                assert_eq!(batched.seek(0), TERMINATED);
2216            }
2217        }
2218    }
2219
2220    #[tokio::test]
2221    async fn document_windows_preserve_nested_boolean_membership_and_next_scores() {
2222        use crate::query::{Collector, CountCollector, ScorerOptions, collect_segment};
2223        use crate::segment::{SegmentBuilder, SegmentBuilderConfig, SegmentId};
2224        use crate::structures::PostingCodec;
2225        use std::sync::Arc;
2226        struct Ids(Vec<u32>);
2227        impl Collector for Ids {
2228            fn needs_scores(&self) -> bool {
2229                false
2230            }
2231            fn collect(
2232                &mut self,
2233                doc: u32,
2234                score: f32,
2235                positions: &[(u32, Vec<crate::query::ScoredPosition>)],
2236            ) {
2237                assert_eq!(score, 0.0);
2238                assert!(positions.is_empty());
2239                self.0.push(doc);
2240            }
2241        }
2242        for codec in [
2243            PostingCodec::Rounded,
2244            PostingCodec::Packed,
2245            PostingCodec::Pfor,
2246            PostingCodec::Simd4x,
2247        ] {
2248            let dir = crate::RamDirectory::new();
2249            let mut schema = crate::SchemaBuilder::default();
2250            let field = schema.add_text_field("text", true, false);
2251            schema.set_positions(field, crate::dsl::PositionMode::TokenPosition);
2252            let schema = Arc::new(schema.build());
2253            let config = SegmentBuilderConfig {
2254                posting_codec: codec,
2255                ..Default::default()
2256            };
2257            let mut builder = SegmentBuilder::new(schema.clone(), config).unwrap();
2258            for doc_id in 0..12_345 {
2259                let mut text = String::from("padding ");
2260                for (term, divisor) in [("alpha", 2), ("beta", 3), ("gamma", 11), ("rare", 997)] {
2261                    if doc_id % divisor == 0 {
2262                        text.push_str(&format!("{term} {term} "));
2263                    }
2264                }
2265                let mut doc = crate::Document::new();
2266                doc.add_text(field, text);
2267                builder.add_document(doc).unwrap();
2268            }
2269            let id = SegmentId::new();
2270            builder.build(&dir, id, None).await.unwrap();
2271            let reader = SegmentReader::open(&dir, id, schema, 16).await.unwrap();
2272            let term = |name| TermQuery::text(field, name);
2273            for shape in 0..9 {
2274                let query = match shape {
2275                    0 => BooleanQuery::new()
2276                        .should(term("alpha"))
2277                        .should(term("beta")),
2278                    1 => BooleanQuery::new()
2279                        .must(term("alpha"))
2280                        .must(term("beta"))
2281                        .should(term("gamma")),
2282                    2 => BooleanQuery::new()
2283                        .must(
2284                            BooleanQuery::new()
2285                                .should(term("alpha"))
2286                                .should(term("beta")),
2287                        )
2288                        .must_not(term("gamma")),
2289                    3 => BooleanQuery::new()
2290                        .must(term("rare"))
2291                        .must(
2292                            BooleanQuery::new()
2293                                .should(term("alpha"))
2294                                .should(term("beta")),
2295                        )
2296                        .must_not(term("gamma")),
2297                    4 => BooleanQuery::new().must(term("alpha")).should(term("beta")),
2298                    5 => BooleanQuery::new().must(term("alpha")).must(term("beta")),
2299                    6 => BooleanQuery::new().must(term("rare")).must(
2300                        BooleanQuery::new()
2301                            .should(term("alpha"))
2302                            .should(term("beta")),
2303                    ),
2304                    7 => BooleanQuery::new()
2305                        .must(term("alpha"))
2306                        .must(term("missing")),
2307                    _ => BooleanQuery::new()
2308                        .must(BooleanQuery::new().must(term("alpha")).must(term("beta")))
2309                        .must(term("gamma")),
2310                };
2311                let expected: Vec<u32> = (0..12_345)
2312                    .filter(|doc| {
2313                        let a = doc % 2 == 0;
2314                        let b = doc % 3 == 0;
2315                        let g = doc % 11 == 0;
2316                        match shape {
2317                            0 => a || b,
2318                            1 => a && b,
2319                            2 => (a || b) && !g,
2320                            3 => doc % 997 == 0 && (a || b) && !g,
2321                            4 => a,
2322                            5 => a && b,
2323                            6 => doc % 997 == 0 && (a || b),
2324                            7 => false,
2325                            _ => a && b && g,
2326                        }
2327                    })
2328                    .collect();
2329                let options = ScorerOptions {
2330                    complete_text_matches: true,
2331                    collect_positions: true,
2332                    ..Default::default()
2333                };
2334                let mut scalar = query
2335                    .scorer_with_options(&reader, 10, options.clone())
2336                    .await
2337                    .unwrap();
2338                let mut batched = query
2339                    .scorer_with_options(&reader, 10, options)
2340                    .await
2341                    .unwrap();
2342                assert_eq!(
2343                    batched.supports_doc_windows(),
2344                    matches!(shape, 0 | 5 | 8),
2345                    "shape={shape}"
2346                );
2347                let mut actual = Vec::new();
2348                while batched.doc() != TERMINATED {
2349                    let base = batched.doc();
2350                    let mut bits = [u64::MAX; super::super::docset::DOC_WINDOW_WORDS];
2351                    batched.fill_doc_window(base, &mut bits);
2352                    for (index, word) in bits.into_iter().enumerate() {
2353                        for bit in 0..64 {
2354                            if word & (1 << bit) != 0 {
2355                                actual.push(base + index as u32 * 64 + bit);
2356                            }
2357                        }
2358                    }
2359                    scalar.seek(base.saturating_add(super::super::docset::DOC_WINDOW_SIZE));
2360                    assert_eq!(batched.doc(), scalar.doc());
2361                    if scalar.doc() != TERMINATED {
2362                        assert_eq!(batched.score().to_bits(), scalar.score().to_bits());
2363                        let positions = |value: Option<crate::query::MatchedPositions>| {
2364                            value.map(|fields| {
2365                                fields
2366                                    .into_iter()
2367                                    .map(|(field, values)| {
2368                                        (
2369                                            field,
2370                                            values
2371                                                .into_iter()
2372                                                .map(|p| (p.position, p.score.to_bits()))
2373                                                .collect::<Vec<_>>(),
2374                                        )
2375                                    })
2376                                    .collect::<Vec<_>>()
2377                            })
2378                        };
2379                        assert_eq!(
2380                            positions(batched.matched_positions()),
2381                            positions(scalar.matched_positions())
2382                        );
2383                    }
2384                }
2385                assert_eq!(actual, expected, "codec={codec:?} shape={shape}");
2386                let mut count = CountCollector::new();
2387                collect_segment(&reader, &query, &mut count).await.unwrap();
2388                assert_eq!(count.count() as usize, expected.len());
2389                let mut ids = Ids(Vec::new());
2390                collect_segment(&reader, &query, &mut ids).await.unwrap();
2391                assert_eq!(ids.0, expected);
2392                #[cfg(feature = "sync")]
2393                {
2394                    let mut sync = query
2395                        .scorer_sync_with_options(
2396                            &reader,
2397                            10,
2398                            ScorerOptions {
2399                                complete_text_matches: true,
2400                                ..Default::default()
2401                            },
2402                        )
2403                        .unwrap();
2404                    let mut sync_ids = Vec::new();
2405                    while sync.doc() != TERMINATED {
2406                        let base = sync.doc();
2407                        let mut bits = [0; super::super::docset::DOC_WINDOW_WORDS];
2408                        sync.fill_doc_window(base, &mut bits);
2409                        for (index, word) in bits.into_iter().enumerate() {
2410                            for bit in 0..64 {
2411                                if word & (1 << bit) != 0 {
2412                                    sync_ids.push(base + index as u32 * 64 + bit);
2413                                }
2414                            }
2415                        }
2416                    }
2417                    assert_eq!(sync_ids, expected);
2418                }
2419            }
2420        }
2421    }
2422
2423    #[tokio::test]
2424    async fn conjunction_chooses_the_rare_term_over_a_common_phrase() {
2425        let dir = crate::RamDirectory::new();
2426        let mut schema = crate::SchemaBuilder::default();
2427        let field = schema.add_text_field("text", true, false);
2428        schema.set_positions(field, crate::dsl::PositionMode::TokenPosition);
2429        schema.set_default_fields(vec!["text".into()]);
2430        let config = crate::IndexConfig {
2431            num_indexing_threads: 1,
2432            num_threads: 1,
2433            ..Default::default()
2434        };
2435        let mut writer = crate::IndexWriter::create(dir.clone(), schema.build(), config.clone())
2436            .await
2437            .unwrap();
2438        for doc_id in 0..32 {
2439            let mut doc = crate::Document::new();
2440            doc.add_text(
2441                field,
2442                if doc_id == 24 {
2443                    "alpha beta rare"
2444                } else {
2445                    "alpha beta"
2446                },
2447            );
2448            writer.add_document(doc).unwrap();
2449        }
2450        writer.commit().await.unwrap();
2451        writer.shutdown().await.unwrap();
2452        let index = crate::Index::open(dir, config).await.unwrap();
2453        let reader = index.reader().await.unwrap();
2454        let searcher = reader.searcher().await.unwrap();
2455        let segment = &searcher.segment_readers()[0];
2456        let parser = searcher.query_parser();
2457        let phrase = parser.parse_strict("\"alpha beta\"").unwrap();
2458        let term = parser.parse_strict("rare").unwrap();
2459        let mut scorer = BooleanScorer {
2460            must: vec![
2461                phrase.scorer(segment, 100).await.unwrap(),
2462                term.scorer(segment, 100).await.unwrap(),
2463            ],
2464            should: Vec::new(),
2465            must_not: Vec::new(),
2466            current_doc: 0,
2467            lead: 0,
2468            doc_limit: 0,
2469        };
2470        scorer.initialize();
2471        assert_eq!(
2472            scorer.lead, 1,
2473            "the rarer term must drive candidate traversal"
2474        );
2475        assert_eq!(scorer.doc(), 24);
2476        assert!(scorer.score() > 0.0);
2477        assert_eq!(scorer.advance(), TERMINATED);
2478    }
2479
2480    struct ObservedDocSet {
2481        docs: Vec<u32>,
2482        offset: usize,
2483        cost: u32,
2484        advances: Arc<std::sync::atomic::AtomicUsize>,
2485    }
2486    impl super::super::DocSet for ObservedDocSet {
2487        fn doc(&self) -> u32 {
2488            self.docs.get(self.offset).copied().unwrap_or(TERMINATED)
2489        }
2490        fn advance(&mut self) -> u32 {
2491            self.advances
2492                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2493            self.offset = (self.offset + 1).min(self.docs.len());
2494            self.doc()
2495        }
2496        fn seek(&mut self, target: u32) -> u32 {
2497            self.offset += self.docs[self.offset..].partition_point(|doc| *doc < target);
2498            self.doc()
2499        }
2500        fn size_hint(&self) -> u32 {
2501            self.cost
2502        }
2503    }
2504    impl Scorer for ObservedDocSet {
2505        fn score(&self) -> f32 {
2506            1.0
2507        }
2508    }
2509
2510    #[test]
2511    fn conjunction_skips_expensive_advances_without_changing_matches_or_scores() {
2512        let expensive = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2513        let cheap = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2514        let mut scorer = BooleanScorer {
2515            must: vec![
2516                Box::new(ObservedDocSet {
2517                    docs: (0..201).collect(),
2518                    offset: 0,
2519                    cost: 201,
2520                    advances: expensive.clone(),
2521                }),
2522                Box::new(ObservedDocSet {
2523                    docs: vec![0, 100, 200],
2524                    offset: 0,
2525                    cost: 3,
2526                    advances: cheap.clone(),
2527                }),
2528            ],
2529            should: Vec::new(),
2530            must_not: Vec::new(),
2531            current_doc: 0,
2532            lead: 0,
2533            doc_limit: 0,
2534        };
2535        scorer.initialize();
2536        assert_eq!(scorer.doc(), 0);
2537        assert_eq!(scorer.score(), 2.0);
2538        assert_eq!(scorer.advance(), 100);
2539        assert_eq!(scorer.score(), 2.0);
2540        assert_eq!(scorer.seek(150), 200);
2541        assert_eq!(scorer.score(), 2.0);
2542        assert_eq!(scorer.advance(), TERMINATED);
2543        assert_eq!(expensive.load(std::sync::atomic::Ordering::Relaxed), 0);
2544        assert_eq!(cheap.load(std::sync::atomic::Ordering::Relaxed), 2);
2545    }
2546
2547    struct ObservedTwoPhase {
2548        doc: DocId,
2549        confirmations: Arc<std::sync::atomic::AtomicUsize>,
2550    }
2551
2552    impl super::super::docset::DocSet for ObservedTwoPhase {
2553        fn doc(&self) -> DocId {
2554            self.doc
2555        }
2556        fn advance(&mut self) -> DocId {
2557            self.seek(self.doc.saturating_add(1))
2558        }
2559        fn seek(&mut self, target: DocId) -> DocId {
2560            self.seek_candidate(target);
2561            while self.doc != TERMINATED && !self.confirm_candidate() {
2562                self.advance_candidate();
2563            }
2564            self.doc
2565        }
2566        fn size_hint(&self) -> u32 {
2567            201
2568        }
2569    }
2570
2571    impl Scorer for ObservedTwoPhase {
2572        fn score(&self) -> Score {
2573            1.0
2574        }
2575        fn advance_candidate(&mut self) -> DocId {
2576            self.seek_candidate(self.doc.saturating_add(1))
2577        }
2578        fn seek_candidate(&mut self, target: DocId) -> DocId {
2579            self.doc = self.doc.max(target);
2580            if self.doc > 200 {
2581                self.doc = TERMINATED;
2582            }
2583            self.doc
2584        }
2585        fn confirm_candidate(&mut self) -> bool {
2586            self.confirmations
2587                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2588            self.doc != TERMINATED && self.doc.is_multiple_of(50)
2589        }
2590    }
2591
2592    #[test]
2593    fn conjunction_confirms_only_aligned_candidates_and_rejects_false_positives() {
2594        let confirmations = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2595        let mut scorer = BooleanScorer {
2596            must: vec![
2597                Box::new(ObservedTwoPhase {
2598                    doc: 0,
2599                    confirmations: confirmations.clone(),
2600                }),
2601                Box::new(ObservedDocSet {
2602                    docs: vec![0, 20, 100, 200],
2603                    offset: 0,
2604                    cost: 4,
2605                    advances: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2606                }),
2607            ],
2608            should: Vec::new(),
2609            must_not: vec![Box::new(ObservedDocSet {
2610                docs: vec![100],
2611                offset: 0,
2612                cost: 1,
2613                advances: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2614            })],
2615            current_doc: 0,
2616            lead: 0,
2617            doc_limit: 0,
2618        };
2619        scorer.initialize();
2620        assert_eq!(scorer.doc(), 0);
2621        assert_eq!(scorer.score(), 2.0);
2622        assert_eq!(scorer.advance(), 200);
2623        assert_eq!(scorer.score(), 2.0);
2624        assert_eq!(scorer.advance(), TERMINATED);
2625        assert_eq!(confirmations.load(std::sync::atomic::Ordering::Relaxed), 4);
2626    }
2627
2628    #[test]
2629    fn disjunction_cardinality_hints_saturate_instead_of_overflowing() {
2630        let scorer = BooleanScorer::disjunction(
2631            (0..2)
2632                .map(|_| {
2633                    Box::new(ObservedDocSet {
2634                        docs: vec![0],
2635                        offset: 0,
2636                        cost: u32::MAX,
2637                        advances: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2638                    }) as Box<dyn Scorer>
2639                })
2640                .collect(),
2641        );
2642        assert_eq!(scorer.size_hint(), u32::MAX);
2643    }
2644
2645    #[test]
2646    fn test_maxscore_eligible_pure_or_same_field() {
2647        // Pure OR query with multiple terms in same field should be MaxScore-eligible
2648        let query = BooleanQuery::new()
2649            .should(TermQuery::text(Field(0), "hello"))
2650            .should(TermQuery::text(Field(0), "world"))
2651            .should(TermQuery::text(Field(0), "foo"));
2652
2653        // All clauses should return term info
2654        assert!(
2655            query
2656                .should
2657                .iter()
2658                .all(|q| matches!(q.decompose(), QueryDecomposition::TextTerm(_)))
2659        );
2660
2661        // All should be same field
2662        let infos: Vec<_> = query
2663            .should
2664            .iter()
2665            .filter_map(|q| match q.decompose() {
2666                QueryDecomposition::TextTerm(info) => Some(info),
2667                _ => None,
2668            })
2669            .collect();
2670        assert_eq!(infos.len(), 3);
2671        assert!(infos.iter().all(|i| i.field == Field(0)));
2672    }
2673
2674    #[test]
2675    fn test_maxscore_not_eligible_different_fields() {
2676        // OR query with terms in different fields should NOT use MaxScore
2677        let query = BooleanQuery::new()
2678            .should(TermQuery::text(Field(0), "hello"))
2679            .should(TermQuery::text(Field(1), "world")); // Different field!
2680
2681        let infos: Vec<_> = query
2682            .should
2683            .iter()
2684            .filter_map(|q| match q.decompose() {
2685                QueryDecomposition::TextTerm(info) => Some(info),
2686                _ => None,
2687            })
2688            .collect();
2689        assert_eq!(infos.len(), 2);
2690        // Fields are different, MaxScore should not be used
2691        assert!(infos[0].field != infos[1].field);
2692    }
2693
2694    #[test]
2695    fn test_term_query_info_extraction() {
2696        let term_query = TermQuery::text(Field(42), "test");
2697        match term_query.decompose() {
2698            QueryDecomposition::TextTerm(info) => {
2699                assert_eq!(info.field, Field(42));
2700                assert_eq!(info.term, b"test");
2701            }
2702            _ => panic!("Expected TextTerm decomposition"),
2703        }
2704    }
2705
2706    #[test]
2707    fn test_boolean_query_no_term_info() {
2708        // BooleanQuery itself should not return term info
2709        let query = BooleanQuery::new().should(TermQuery::text(Field(0), "hello"));
2710
2711        assert!(matches!(query.decompose(), QueryDecomposition::Opaque));
2712    }
2713}
2714
2715#[cfg(test)]
2716#[path = "boolean/conjunction_window_tests.rs"]
2717mod conjunction_window_tests;