lucisearch 0.8.0

Embeddable, in-process search engine — the SQLite/DuckDB of Elasticsearch
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
//! MaxScore disjunction scorer with block-level skip (WAND optimization).
//!
//! Partitions sub-scorers into essential and non-essential based on their
//! `max_score()` bounds. Essential scorers drive iteration; non-essential
//! are only evaluated when the essential sum could be competitive.
//!
//! Supports two score combination modes:
//! - `Sum`: total = sum(matching scores) — used by bool/should
//! - `DisMax`: total = max(matching) + tie_breaker * sum(others) — used by dis_max
//!
//! See [[architecture-query-execution#WAND / MaxScore Optimization]] and
//! [[optimization-block-max-wand]].

use crate::core::{DocId, NO_MORE_DOCS, Scorer, TwoPhaseIterator};

/// How to combine sub-scorer scores for a matching document.
#[derive(Clone, Copy)]
pub(crate) enum CombineMode {
    /// Sum of all matching scores (bool/should).
    Sum,
    /// max(matching) + tie_breaker * sum(others) (dis_max).
    DisMax { tie_breaker: f32 },
}

/// MaxScore disjunction scorer.
///
/// Sub-scorers are sorted by `max_score()` ascending. The partition splits
/// them into non-essential (head, low max_score) and essential (tail).
///
/// Supports `min_should_match >= 1`. For `min_should_match > 1`, the
/// scorer enforces a count threshold in addition to the score threshold —
/// a document must have at least `min_should_match` matching sub-scorers
/// AND a competitive score to be returned.
pub struct WANDScorer {
    /// Sub-scorers sorted by max_score ascending.
    /// `scorers[0..partition_idx]` = non-essential
    /// `scorers[partition_idx..]` = essential
    scorers: Vec<Box<dyn Scorer>>,

    /// Cached doc_ids for each scorer (avoids repeated vtable dispatch).
    /// Updated whenever a scorer advances.
    doc_ids: Vec<DocId>,

    /// Upper-bound prefix values used for the partition decision.
    /// For Sum: `prefix[i]` = cumulative sum of max_score for `scorers[0..=i]`.
    /// For DisMax: `prefix[i]` = max_scores[i] (since sorted ascending, this
    /// is the max of max_scores[0..=i]).
    max_score_prefix: Vec<f32>,

    /// Index into scorers: `scorers[partition_idx..]` are essential.
    partition_idx: usize,

    /// Current minimum competitive score (from collector feedback).
    min_competitive_score: f32,

    /// Current document state.
    current: DocId,
    current_score: f32,

    /// Score combination mode.
    combine_mode: CombineMode,

    /// Minimum number of matching sub-scorers required. Defaults to 1
    /// (standard disjunction). Lucene unifies all `min_should_match`
    /// values 1..N-1 in a single WANDScorer; Luci does the same after
    /// [[fix-min-should-match-unit-cost]] (which deletes the separate
    /// `MinShouldMatchScorer`).
    min_should_match: usize,
}

impl WANDScorer {
    pub fn new(scorers: Vec<Box<dyn Scorer>>) -> Self {
        Self::with_params(scorers, CombineMode::Sum, 1)
    }

    pub(crate) fn new_dis_max(scorers: Vec<Box<dyn Scorer>>, tie_breaker: f32) -> Self {
        Self::with_params(scorers, CombineMode::DisMax { tie_breaker }, 1)
    }

    pub(crate) fn new_min_should_match(
        scorers: Vec<Box<dyn Scorer>>,
        min_should_match: usize,
    ) -> Self {
        assert!(min_should_match >= 1, "min_should_match must be >= 1");
        Self::with_params(scorers, CombineMode::Sum, min_should_match)
    }

    fn with_params(
        mut scorers: Vec<Box<dyn Scorer>>,
        combine_mode: CombineMode,
        min_should_match: usize,
    ) -> Self {
        assert!(!scorers.is_empty(), "WAND needs at least one scorer");

        // Remove exhausted scorers
        scorers.retain(|s| s.doc_id() != NO_MORE_DOCS);

        // Sort by max_score ascending (lowest max contribution first)
        scorers.sort_by(|a, b| {
            a.max_score()
                .partial_cmp(&b.max_score())
                .unwrap_or(std::cmp::Ordering::Equal)
        });

        // Build prefix values based on combine mode
        let max_score_prefix = Self::build_prefix(&scorers, combine_mode);

        let doc_ids: Vec<DocId> = scorers.iter().map(|s| s.doc_id()).collect();

        let mut wand = Self {
            scorers,
            doc_ids,
            max_score_prefix,
            partition_idx: 0, // all essential initially
            min_competitive_score: 0.0,
            current: NO_MORE_DOCS,
            current_score: 0.0,
            combine_mode,
            min_should_match,
        };

        // Position on the first competitive doc
        if !wand.scorers.is_empty() {
            wand.find_next_competitive();
        }

        wand
    }

    /// Build prefix values for the partition decision.
    fn build_prefix(scorers: &[Box<dyn Scorer>], mode: CombineMode) -> Vec<f32> {
        match mode {
            CombineMode::Sum => {
                let mut prefix = Vec::with_capacity(scorers.len());
                let mut cumulative = 0.0f32;
                for s in scorers {
                    cumulative += s.max_score();
                    prefix.push(cumulative);
                }
                prefix
            }
            CombineMode::DisMax { tie_breaker } => {
                // For dis_max, the upper bound of scorers[0..=i] is:
                // max(max_scores[0..=i]) + tie_breaker * sum(max_scores[0..i])
                // Since sorted ascending, max = max_scores[i].
                let mut prefix = Vec::with_capacity(scorers.len());
                let mut sum_prev = 0.0f32;
                for s in scorers {
                    let ms = s.max_score();
                    prefix.push(ms + tie_breaker * sum_prev);
                    sum_prev += ms;
                }
                prefix
            }
        }
    }

    /// Recompute the essential/non-essential partition based on
    /// `min_competitive_score`.
    fn update_partition(&mut self) {
        self.partition_idx = 0;
        for i in 0..self.scorers.len() {
            if self.max_score_prefix[i] >= self.min_competitive_score {
                break;
            }
            self.partition_idx = i + 1;
        }
    }

    /// Find the minimum doc ID among essential scorers using cached doc_ids.
    #[inline(always)]
    fn min_essential_doc(&self) -> DocId {
        let mut min_doc = NO_MORE_DOCS;
        for &d in &self.doc_ids[self.partition_idx..] {
            if d < min_doc {
                min_doc = d;
            }
        }
        min_doc
    }

    /// Accumulate a score into the dis_max running state.
    #[inline(always)]
    fn dis_max_accum(score: f32, max_score: &mut f32, sum_other: &mut f32) {
        if score > *max_score {
            *sum_other += *max_score;
            *max_score = score;
        } else {
            *sum_other += score;
        }
    }

    /// Find the next document that could be competitive and set `current`.
    /// Uses cached doc_ids to avoid redundant vtable dispatches.
    fn find_next_competitive(&mut self) {
        let n = self.scorers.len();

        loop {
            if self.partition_idx >= n {
                self.current = NO_MORE_DOCS;
                self.current_score = 0.0;
                return;
            }

            let min_doc = self.min_essential_doc();
            if min_doc == NO_MORE_DOCS {
                self.current = NO_MORE_DOCS;
                self.current_score = 0.0;
                return;
            }

            // Score essential scorers at min_doc and count matches
            let mut essential_sum = 0.0f32;
            let mut essential_max = 0.0f32;
            let mut essential_sum_other = 0.0f32;
            let mut essential_count: usize = 0;
            for i in self.partition_idx..n {
                if self.doc_ids[i] == min_doc {
                    let s = self.scorers[i].score();
                    essential_sum += s;
                    essential_count += 1;
                    Self::dis_max_accum(s, &mut essential_max, &mut essential_sum_other);
                }
            }

            // Count-based early exit: even if every non-essential scorer
            // matches this doc, we can't reach min_should_match. Skip
            // without doing the WAND pre-check or the non-essential
            // advance work. (For min_should_match=1, this never triggers
            // because essential_count >= 1 here.)
            // See [[fix-min-should-match-unit-cost]].
            if essential_count + self.partition_idx < self.min_should_match {
                // Advance essential past min_doc and continue.
                for i in self.partition_idx..n {
                    if self.doc_ids[i] == min_doc {
                        self.doc_ids[i] = self.scorers[i].next();
                    }
                }
                continue;
            }

            // Block-max pre-check on non-essential scorers
            let potentially_competitive = if self.min_competitive_score == 0.0 {
                true
            } else {
                match self.combine_mode {
                    CombineMode::Sum => {
                        let mut non_essential_upper = 0.0f32;
                        for i in 0..self.partition_idx {
                            non_essential_upper += self.scorers[i].block_max_score(min_doc);
                        }
                        essential_sum + non_essential_upper >= self.min_competitive_score
                    }
                    CombineMode::DisMax { tie_breaker } => {
                        // Upper bound: a non-essential scorer could be the max.
                        // Conservative bound: max(essential_max, max_non_essential_block)
                        //   + tie_breaker * (essential_sum + sum_non_essential_block - that_max)
                        let mut ne_max_block = 0.0f32;
                        let mut ne_sum_block = 0.0f32;
                        for i in 0..self.partition_idx {
                            let bm = self.scorers[i].block_max_score(min_doc);
                            ne_sum_block += bm;
                            if bm > ne_max_block {
                                ne_max_block = bm;
                            }
                        }
                        let overall_max = essential_max.max(ne_max_block);
                        let rest = essential_sum + ne_sum_block - overall_max;
                        overall_max + tie_breaker * rest >= self.min_competitive_score
                    }
                }
            };

            // Advance non-essential scorers, compute total score and count
            let mut total_score = match self.combine_mode {
                CombineMode::Sum => essential_sum,
                CombineMode::DisMax { tie_breaker } => {
                    essential_max + tie_breaker * essential_sum_other
                }
            };
            let mut total_count = essential_count;

            if potentially_competitive && self.partition_idx > 0 {
                let mut all_max = essential_max;
                let mut all_sum = essential_sum;
                for i in 0..self.partition_idx {
                    let advanced = self.scorers[i].advance(min_doc);
                    self.doc_ids[i] = advanced;
                    if advanced == min_doc {
                        let s = self.scorers[i].score();
                        all_sum += s;
                        total_count += 1;
                        Self::dis_max_accum(s, &mut all_max, &mut essential_sum_other);
                    }
                }
                total_score = match self.combine_mode {
                    CombineMode::Sum => all_sum,
                    CombineMode::DisMax { tie_breaker } => {
                        all_max + tie_breaker * (all_sum - all_max)
                    }
                };
            }

            // Advance essential scorers past min_doc (update cached doc_ids)
            for i in self.partition_idx..n {
                if self.doc_ids[i] == min_doc {
                    self.doc_ids[i] = self.scorers[i].next();
                }
            }

            // Final emit check: must satisfy both score competitiveness
            // AND the min_should_match count threshold.
            if potentially_competitive && total_score > 0.0 && total_count >= self.min_should_match
            {
                self.current = min_doc;
                self.current_score = total_score;
                return;
            }
        }
    }
}

impl Scorer for WANDScorer {
    fn doc_id(&self) -> DocId {
        self.current
    }

    fn next(&mut self) -> DocId {
        if self.current == NO_MORE_DOCS {
            return NO_MORE_DOCS;
        }
        self.find_next_competitive();
        self.current
    }

    fn advance(&mut self, target: DocId) -> DocId {
        if self.current >= target {
            return self.current;
        }

        // Advance all scorers to at least target (update cached doc_ids)
        for i in 0..self.scorers.len() {
            if self.doc_ids[i] < target {
                self.doc_ids[i] = self.scorers[i].advance(target);
            }
        }

        self.find_next_competitive();
        self.current
    }

    fn score(&mut self) -> f32 {
        self.current_score
    }

    fn two_phase(&mut self) -> Option<&mut dyn TwoPhaseIterator> {
        None
    }

    fn max_score(&self) -> f32 {
        self.max_score_prefix.last().copied().unwrap_or(0.0)
    }

    fn set_min_competitive_score(&mut self, min_score: f32) {
        if min_score > self.min_competitive_score {
            self.min_competitive_score = min_score;
            self.update_partition();

            if self.partition_idx >= self.scorers.len() {
                self.current = NO_MORE_DOCS;
                self.current_score = 0.0;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct VecScorer {
        docs: Vec<(DocId, f32)>,
        pos: usize,
        max: f32,
    }

    impl VecScorer {
        fn new(docs: Vec<(u32, f32)>, max: f32) -> Box<dyn Scorer> {
            Box::new(Self {
                docs: docs
                    .into_iter()
                    .map(|(id, s)| (DocId::new(id), s))
                    .collect(),
                pos: 0,
                max,
            })
        }
        fn current(&self) -> (DocId, f32) {
            if self.pos < self.docs.len() {
                self.docs[self.pos]
            } else {
                (NO_MORE_DOCS, 0.0)
            }
        }
    }

    impl Scorer for VecScorer {
        fn doc_id(&self) -> DocId {
            self.current().0
        }
        fn next(&mut self) -> DocId {
            if self.pos < self.docs.len() {
                self.pos += 1;
            }
            self.current().0
        }
        fn advance(&mut self, target: DocId) -> DocId {
            while self.pos < self.docs.len() && self.docs[self.pos].0 < target {
                self.pos += 1;
            }
            self.current().0
        }
        fn score(&mut self) -> f32 {
            self.current().1
        }
        fn two_phase(&mut self) -> Option<&mut dyn TwoPhaseIterator> {
            None
        }
        fn max_score(&self) -> f32 {
            self.max
        }
    }

    /// Gold standard: WANDScorer produces identical results to exhaustive
    /// collection when min_competitive_score is not set.
    #[test]
    fn wand_matches_exhaustive() {
        let s1 = VecScorer::new(vec![(0, 1.0), (2, 1.0), (4, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(1, 2.0), (2, 2.0), (3, 2.0)], 2.0);

        let mut wand = WANDScorer::new(vec![s1, s2]);

        let mut results = Vec::new();
        while wand.doc_id() != NO_MORE_DOCS {
            results.push((wand.doc_id().as_u32(), wand.score()));
            wand.next();
        }

        assert_eq!(
            results,
            vec![(0, 1.0), (1, 2.0), (2, 3.0), (3, 2.0), (4, 1.0)]
        );
    }

    #[test]
    fn wand_three_scorers() {
        let s1 = VecScorer::new(vec![(5, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(5, 2.0)], 2.0);
        let s3 = VecScorer::new(vec![(5, 3.0)], 3.0);

        let mut wand = WANDScorer::new(vec![s1, s2, s3]);
        assert_eq!(wand.doc_id(), DocId::new(5));
        assert_eq!(wand.score(), 6.0);
        assert_eq!(wand.next(), NO_MORE_DOCS);
    }

    #[test]
    fn wand_advance() {
        let s1 = VecScorer::new(vec![(0, 1.0), (5, 1.0), (10, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(3, 2.0), (7, 2.0), (10, 2.0)], 2.0);

        let mut wand = WANDScorer::new(vec![s1, s2]);
        assert_eq!(wand.advance(DocId::new(5)), DocId::new(5));
        assert_eq!(wand.score(), 1.0);
        assert_eq!(wand.advance(DocId::new(10)), DocId::new(10));
        assert_eq!(wand.score(), 3.0);
    }

    #[test]
    fn wand_partition_moves_with_threshold() {
        // s1 has max_score=1.0, s2 has max_score=5.0
        let s1 = VecScorer::new(vec![(0, 0.5), (1, 0.5)], 1.0);
        let s2 = VecScorer::new(vec![(0, 4.0), (1, 4.0)], 5.0);

        let mut wand = WANDScorer::new(vec![s1, s2]);
        // Initially all essential (partition_idx=0)
        assert_eq!(wand.partition_idx, 0);

        // Set threshold > s1.max_score but < s1.max + s2.max
        wand.set_min_competitive_score(2.0);
        // s1 (max_score=1.0) becomes non-essential since prefix[0]=1.0 < 2.0
        assert_eq!(wand.partition_idx, 1);
    }

    #[test]
    fn wand_all_non_essential_exhausts() {
        let s1 = VecScorer::new(vec![(0, 0.5)], 1.0);
        let s2 = VecScorer::new(vec![(0, 1.0)], 2.0);

        let mut wand = WANDScorer::new(vec![s1, s2]);
        assert_ne!(wand.doc_id(), NO_MORE_DOCS);

        // Set threshold higher than total max_score
        wand.set_min_competitive_score(10.0);
        assert_eq!(wand.doc_id(), NO_MORE_DOCS);
    }

    #[test]
    fn wand_non_overlapping() {
        let s1 = VecScorer::new(vec![(0, 1.0), (2, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(1, 2.0), (3, 2.0)], 2.0);

        let mut wand = WANDScorer::new(vec![s1, s2]);
        let mut docs = Vec::new();
        while wand.doc_id() != NO_MORE_DOCS {
            docs.push(wand.doc_id().as_u32());
            wand.next();
        }
        assert_eq!(docs, vec![0, 1, 2, 3]);
    }

    #[test]
    fn wand_skips_non_competitive_docs() {
        // 3 scorers, set high threshold → only docs scoring above threshold survive
        let s1 = VecScorer::new(vec![(0, 1.0), (1, 1.0), (2, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(1, 2.0), (2, 2.0)], 2.0);
        let s3 = VecScorer::new(vec![(2, 3.0)], 3.0);

        let mut wand = WANDScorer::new(vec![s1, s2, s3]);

        // Doc 0 (score=1.0) was found during construction before any threshold.
        assert_eq!(wand.doc_id(), DocId::new(0));
        assert_eq!(wand.score(), 1.0);

        // Set threshold so only doc 2 (score=6.0) is competitive going forward.
        // Doc 1 (score=3.0) should be skipped.
        wand.set_min_competitive_score(5.0);
        wand.next();

        assert_eq!(wand.doc_id(), DocId::new(2));
        assert_eq!(wand.score(), 6.0);
        assert_eq!(wand.next(), NO_MORE_DOCS);
    }

    #[test]
    fn wand_max_score() {
        let s1 = VecScorer::new(vec![(0, 1.0)], 1.5);
        let s2 = VecScorer::new(vec![(0, 2.0)], 2.5);

        let wand = WANDScorer::new(vec![s1, s2]);
        assert_eq!(wand.max_score(), 4.0);
    }

    // --- DisMax mode tests ---

    #[test]
    fn dis_max_wand_scores_correctly() {
        // Two scorers both matching doc 0: scores 3.0 and 1.0
        // dis_max with tie_breaker=0: score = max(3.0, 1.0) = 3.0
        let s1 = VecScorer::new(vec![(0, 3.0), (2, 1.0)], 3.0);
        let s2 = VecScorer::new(vec![(0, 1.0), (1, 2.0)], 2.0);

        let mut wand = WANDScorer::new_dis_max(vec![s1, s2], 0.0);

        let mut results = Vec::new();
        while wand.doc_id() != NO_MORE_DOCS {
            results.push((wand.doc_id().as_u32(), wand.score()));
            wand.next();
        }

        assert_eq!(results, vec![(0, 3.0), (1, 2.0), (2, 1.0)]);
    }

    #[test]
    fn dis_max_wand_with_tie_breaker() {
        // scores [3.0, 1.0] with tie_breaker=0.5: 3.0 + 0.5*1.0 = 3.5
        let s1 = VecScorer::new(vec![(0, 3.0)], 3.0);
        let s2 = VecScorer::new(vec![(0, 1.0)], 1.0);

        let mut wand = WANDScorer::new_dis_max(vec![s1, s2], 0.5);
        assert_eq!(wand.doc_id(), DocId::new(0));
        assert_eq!(wand.score(), 3.5);
    }

    #[test]
    fn dis_max_wand_single_match() {
        // Only one scorer matches — score equals that scorer's score
        let s1 = VecScorer::new(vec![(0, 5.0)], 5.0);
        let s2 = VecScorer::new(vec![(1, 2.0)], 2.0);

        let mut wand = WANDScorer::new_dis_max(vec![s1, s2], 0.0);
        assert_eq!(wand.doc_id(), DocId::new(0));
        assert_eq!(wand.score(), 5.0);
        assert_eq!(wand.next(), DocId::new(1));
        assert_eq!(wand.score(), 2.0);
    }

    // --- min_should_match tests ---

    /// Basic min_should_match=2 case: doc must match at least 2 of 3 scorers.
    #[test]
    fn wand_msm_basic() {
        // s1: docs [0, 1, 2]
        // s2: docs [1, 2]
        // s3: docs [2]
        let s1 = VecScorer::new(vec![(0, 1.0), (1, 1.0), (2, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(1, 1.0), (2, 1.0)], 1.0);
        let s3 = VecScorer::new(vec![(2, 1.0)], 1.0);

        let mut wand = WANDScorer::new_min_should_match(vec![s1, s2, s3], 2);
        // Doc 0: only s1 → 1 match, < 2, skip
        // Doc 1: s1+s2 → 2 matches, return
        // Doc 2: s1+s2+s3 → 3 matches, return
        assert_eq!(wand.doc_id(), DocId::new(1));
        assert_eq!(wand.score(), 2.0);
        assert_eq!(wand.next(), DocId::new(2));
        assert_eq!(wand.score(), 3.0);
        assert_eq!(wand.next(), NO_MORE_DOCS);
    }

    /// min_should_match=3 with 5 scorers: only docs matching 3+ are returned.
    #[test]
    fn wand_msm_three_of_five() {
        let s1 = VecScorer::new(vec![(0, 1.0), (1, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(0, 1.0), (1, 1.0)], 1.0);
        let s3 = VecScorer::new(vec![(0, 1.0), (2, 1.0)], 1.0);
        let s4 = VecScorer::new(vec![(2, 1.0)], 1.0);
        let s5 = VecScorer::new(vec![(2, 1.0)], 1.0);

        let mut wand = WANDScorer::new_min_should_match(vec![s1, s2, s3, s4, s5], 3);
        // Doc 0: s1+s2+s3 → 3 matches, score 3.0
        // Doc 1: s1+s2 → 2 matches, < 3, skip
        // Doc 2: s3+s4+s5 → 3 matches, score 3.0
        assert_eq!(wand.doc_id(), DocId::new(0));
        assert_eq!(wand.score(), 3.0);
        assert_eq!(wand.next(), DocId::new(2));
        assert_eq!(wand.score(), 3.0);
        assert_eq!(wand.next(), NO_MORE_DOCS);
    }

    /// Score sum from ALL matching clauses, not just minimum.
    /// This is the v0.6.1 advanceAllTail behavior preserved in the unified
    /// scorer. See [[fix-min-should-match-incomplete-scoring]].
    #[test]
    fn wand_msm_scores_all_matching() {
        // 4 scorers all matching doc 0 with distinct scores. min=2.
        let s1 = VecScorer::new(vec![(0, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(0, 2.0)], 2.0);
        let s3 = VecScorer::new(vec![(0, 3.0)], 3.0);
        let s4 = VecScorer::new(vec![(0, 4.0)], 4.0);

        let mut wand = WANDScorer::new_min_should_match(vec![s1, s2, s3, s4], 2);
        assert_eq!(wand.doc_id(), DocId::new(0));
        // Score must include ALL 4 matching clauses (1+2+3+4=10),
        // not just the first 2 to satisfy min_should_match.
        assert_eq!(wand.score(), 10.0);
    }

    /// min_should_match equal to scorer count (degenerate to conjunction).
    /// This case isn't dispatched here in production (boolean.rs uses
    /// ConjunctionScorer for min == n) but the WANDScorer must handle it
    /// correctly if called.
    #[test]
    fn wand_msm_all_required() {
        let s1 = VecScorer::new(vec![(0, 1.0), (1, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(1, 1.0), (2, 1.0)], 1.0);

        let mut wand = WANDScorer::new_min_should_match(vec![s1, s2], 2);
        // Only doc 1 has both scorers
        assert_eq!(wand.doc_id(), DocId::new(1));
        assert_eq!(wand.score(), 2.0);
        assert_eq!(wand.next(), NO_MORE_DOCS);
    }

    /// No doc satisfies min_should_match.
    #[test]
    fn wand_msm_no_match() {
        let s1 = VecScorer::new(vec![(0, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(1, 1.0)], 1.0);

        let wand = WANDScorer::new_min_should_match(vec![s1, s2], 2);
        // No doc matches both
        assert_eq!(wand.doc_id(), NO_MORE_DOCS);
    }

    /// min_should_match=1 must behave identically to standard disjunction.
    #[test]
    fn wand_msm_one_equivalent_to_default() {
        let s1 = VecScorer::new(vec![(0, 1.0), (2, 1.0), (4, 1.0)], 1.0);
        let s2 = VecScorer::new(vec![(1, 2.0), (2, 2.0), (3, 2.0)], 2.0);

        let mut wand = WANDScorer::new_min_should_match(vec![s1, s2], 1);
        let mut results = Vec::new();
        while wand.doc_id() != NO_MORE_DOCS {
            results.push((wand.doc_id().as_u32(), wand.score()));
            wand.next();
        }
        assert_eq!(
            results,
            vec![(0, 1.0), (1, 2.0), (2, 3.0), (3, 2.0), (4, 1.0)]
        );
    }
}