Skip to main content

sui_inverted_index/bitmap_query/
iter.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Synchronous iterator evaluator for DNF bitmap queries.
5//!
6//! A single flat driver merge-joins every leaf scan against one shared *floor*
7//! (the slowest leaf's position). At the floor bucket it evaluates the query —
8//! intersect each term's included dimensions, subtract its excluded ones, then
9//! union across terms — and emits a watermark at the floor. Because leaves only
10//! ever advance at the floor (peeked one bucket ahead), no branch can run ahead
11//! of the others: the resume cursor is always within one sparse read of every
12//! leaf, and there is no windowing/parking machinery to get wrong. Mirrors the
13//! async [`super::stream`] evaluator, which shares the per-bucket evaluation
14//! ([`eval_term_at_bucket`]) and is cross-checked against this one in tests.
15//!
16//! Like the stream evaluator, this evaluator owns the scan budget: it wraps
17//! every backend leaf iterator in [`BudgetedBucketIter`], which charges each
18//! pulled bucket row to one budget shared by all leaves and turns exhaustion
19//! into [`LeafStop::BudgetExhausted`]. Backend iterators only scan storage and
20//! report their own stops (cancellation, faults).
21//!
22//! Watermark emission is paced by a [`WatermarkPacing`]: [`PacedBucketIter`]
23//! applies it to the unpaced evaluation's output.
24
25use std::collections::VecDeque;
26use std::ops::Range;
27
28use roaring::RoaringBitmap;
29
30use super::BitmapBucketIteratorSource;
31use super::BitmapQuery;
32use super::BitmapScanBudget;
33use super::BitmapScanMetrics;
34use super::BucketItem;
35use super::DedupedQuery;
36use super::LeafHead;
37use super::LeafStop;
38use super::ObserveOnDrop;
39use super::ScanDirection;
40use super::ScanStop;
41use super::SkipPolicy;
42use super::WatermarkPacer;
43use super::WatermarkPacing;
44use super::Watermarked;
45use super::WatermarkedBucket;
46use super::advance_in_direction;
47use super::bound_in_direction;
48use super::bucket_edges;
49use super::build_term_specs;
50use super::collapse;
51use super::count_on_floor_refs;
52use super::eval_term_at_bucket;
53use super::frontier_advanced;
54use super::leaf_skip_targets;
55use super::recompute_unreferenced;
56use super::strictly_before;
57use super::take_snapshot_bitmap;
58
59struct Leaf<I> {
60    iter: I,
61    peeked: Option<Option<BucketItem>>,
62    drained: u64,
63}
64
65impl<I: Iterator<Item = BucketItem>> Leaf<I> {
66    fn new(iter: I) -> Self {
67        Self {
68            iter,
69            peeked: None,
70            drained: 0,
71        }
72    }
73
74    fn peek(&mut self) -> Option<&BucketItem> {
75        if self.peeked.is_none() {
76            self.peeked = Some(self.iter.next());
77        }
78        self.peeked.as_ref().and_then(Option::as_ref)
79    }
80
81    fn next(&mut self) -> Option<BucketItem> {
82        match self.peeked.take() {
83            Some(item) => item,
84            None => self.iter.next(),
85        }
86    }
87}
88
89impl<I: super::SeekableBucketIterator> Leaf<I> {
90    /// Reposition the leaf so its next bucket is the first at or past `bucket`,
91    /// dropping whatever sits in the peek slot. Returns whether the dropped
92    /// head was a fetched (and therefore charged) bucket row.
93    fn seek_bucket(&mut self, bucket: u64) -> bool {
94        let discarded_row = matches!(self.peeked.as_ref(), Some(Some(Ok(_))));
95        self.peeked = None;
96        self.iter.seek_bucket(bucket);
97        discarded_row
98    }
99}
100
101/// Leaf scan charged against the evaluation's shared [`BitmapScanBudget`].
102/// Mirrors the stream evaluator's `budgeted_bucket_stream`: the leaf's first
103/// bucket row is charged with `take_first` (always succeeds, so every leaf can
104/// report its first position), later rows with `try_take`. On underflow it
105/// yields [`LeafStop::BudgetExhausted`] once and then ends — never a silent
106/// EOF. Backend stops pass through uncharged.
107struct BudgetedBucketIter<I> {
108    inner: I,
109    budget: BitmapScanBudget,
110    first_bucket_pending: bool,
111    exhausted: bool,
112}
113
114impl<I> BudgetedBucketIter<I> {
115    fn new(inner: I, budget: BitmapScanBudget) -> Self {
116        Self {
117            inner,
118            budget,
119            first_bucket_pending: true,
120            exhausted: false,
121        }
122    }
123}
124
125impl<I: Iterator<Item = BucketItem>> Iterator for BudgetedBucketIter<I> {
126    type Item = BucketItem;
127
128    fn next(&mut self) -> Option<BucketItem> {
129        if self.exhausted {
130            return None;
131        }
132        let item = self.inner.next()?;
133        if item.is_err() {
134            return Some(item);
135        }
136        if self.first_bucket_pending {
137            self.budget.take_first();
138            self.first_bucket_pending = false;
139        } else if !self.budget.try_take() {
140            self.exhausted = true;
141            return Some(Err(LeafStop::BudgetExhausted));
142        }
143        Some(item)
144    }
145}
146
147impl<I: super::SeekableBucketIterator> super::SeekableBucketIterator for BudgetedBucketIter<I> {
148    /// A seek repositions the same logical leaf, so the first-bucket
149    /// reservation is not granted again.
150    fn seek_bucket(&mut self, bucket: u64) {
151        self.inner.seek_bucket(bucket);
152    }
153}
154
155/// The evaluator's unpaced output with a [`WatermarkPacer`] applied. When the
156/// unpaced iterator ends, it emits the watermark [`WatermarkPacer::finish`]
157/// returns, if any, and then stays ended.
158struct PacedBucketIter<I> {
159    unpaced: I,
160    pacer: WatermarkPacer,
161    unpaced_ended: bool,
162}
163
164impl<I: Iterator<Item = WatermarkedBucket>> Iterator for PacedBucketIter<I> {
165    type Item = WatermarkedBucket;
166
167    fn next(&mut self) -> Option<WatermarkedBucket> {
168        while !self.unpaced_ended {
169            match self.unpaced.next() {
170                Some(output) => {
171                    if let Some(paced) = self.pacer.pace(output) {
172                        return Some(paced);
173                    }
174                }
175                None => {
176                    self.unpaced_ended = true;
177                    return self
178                        .pacer
179                        .finish()
180                        .map(|position| Ok(Watermarked::Watermark(position)));
181                }
182            }
183        }
184        None
185    }
186}
187
188/// Evaluate a DNF `BitmapQuery` as an ordered iterator of marked bucket bitmaps.
189/// Output emits `Watermarked::Item((bucket_id, bitmap))` interleaved with
190/// `Watermarked::Watermark(p)` derived from the slowest leaf's progress.
191///
192/// `budget` caps the bucket rows charged across all leaf scans, including dead
193/// rows drained during gap catch-up; exhaustion ends the iterator with
194/// `ScanStop::ScanLimit` carrying the merged frontier. `on_metrics` fires
195/// exactly once when the returned iterator is dropped, whether or not it was
196/// polled.
197///
198/// `pacing` decides which watermarks are emitted (see [`WatermarkPacing`]);
199/// the bucket rows it counts are the ones charged against `budget`.
200///
201/// Unlike [`super::eval_bitmap_query_stream`], there is no `budget >=
202/// unique_leaf_count` misconfiguration guard: callers such as the fullnode
203/// pass a per-chunk budget that legitimately falls below the leaf count near
204/// the end of a request, and the per-leaf first-bucket reservation still lets
205/// every leaf report its first position.
206pub fn eval_bitmap_query_bucket_iter<'a, S, F>(
207    source: S,
208    query: BitmapQuery,
209    range: Range<u64>,
210    bucket_size: u64,
211    direction: ScanDirection,
212    budget: u64,
213    policy: SkipPolicy,
214    pacing: WatermarkPacing,
215    on_metrics: F,
216) -> impl Iterator<Item = WatermarkedBucket> + 'a
217where
218    S: BitmapBucketIteratorSource<'a>,
219    F: FnOnce(BitmapScanMetrics) + 'a,
220{
221    // Build one leaf per unique dimension key. Every term addresses these
222    // deduplicated leaves by index, so a shared dimension is scanned once.
223    let DedupedQuery {
224        keys: unique_keys,
225        mut terms,
226    } = build_term_specs(query.terms);
227    let budget = BitmapScanBudget::new(budget);
228    let mut leaves: Vec<Leaf<BudgetedBucketIter<S::Iter>>> = Vec::with_capacity(unique_keys.len());
229    for key in unique_keys {
230        leaves.push(Leaf::new(BudgetedBucketIter::new(
231            source.scan_bucket_iter(key, range.clone(), direction),
232            budget.clone(),
233        )));
234    }
235    // The iterator closure owns the guard, so `on_metrics` fires when the
236    // caller drops the returned iterator.
237    let observer = ObserveOnDrop {
238        budget: budget.clone(),
239        callback: Some(on_metrics),
240    };
241    let pacer = WatermarkPacer::new(pacing, policy, budget.clone(), range.clone(), bucket_size);
242
243    let leaf_count = leaves.len();
244    let terminus = if direction.is_ascending() {
245        range.end
246    } else {
247        range.start
248    };
249    let request_floor = if direction.is_ascending() {
250        range.start
251    } else {
252        range.end
253    };
254    // `unreferenced[i]` retires a leaf once no satisfiable term references it
255    // or its scan is permanently exhausted.
256    let mut unreferenced = vec![false; leaf_count];
257    // `front[i]` is the furthest position proven safe for this leaf, either by
258    // physical scanning or by a conjunction's leapfrog bound. The slowest live
259    // front limits the resume cursor.
260    let mut front = vec![request_floor; leaf_count];
261    // The request floor is a baseline, not progress earned by evaluation.
262    let mut progress_frontier = Some(request_floor);
263    let mut done = false;
264    let mut pending: VecDeque<WatermarkedBucket> = VecDeque::new();
265
266    let unpaced = std::iter::from_fn(move || {
267        let _observer = &observer;
268        loop {
269            if let Some(out) = pending.pop_front() {
270                return Some(out);
271            }
272            if done {
273                return None;
274            }
275
276            // Peek every active leaf without consuming it, then classify its
277            // current bucket, EOF, or error state.
278            let mut class: Vec<Option<LeafHead>> = (0..leaf_count).map(|_| None).collect();
279            for i in 0..leaf_count {
280                if unreferenced[i] {
281                    continue;
282                }
283                match leaves[i].peek() {
284                    Some(Ok((bucket, _))) => {
285                        let (pre, _) = bucket_edges(*bucket, bucket_size, &range, direction);
286                        front[i] = advance_in_direction(front[i], pre, direction);
287                        class[i] = Some(LeafHead::Bucket(*bucket));
288                    }
289                    None => {
290                        front[i] = advance_in_direction(front[i], terminus, direction);
291                        class[i] = Some(LeafHead::Eof);
292                    }
293                    Some(Err(_)) => class[i] = Some(LeafHead::Error),
294                }
295            }
296
297            // An include at EOF makes its conjunction permanently empty. A
298            // deduplicated include may make several conjunctions unsatisfiable.
299            for term in terms.iter_mut() {
300                if !term.unsatisfiable
301                    && term
302                        .includes
303                        .iter()
304                        .any(|&i| matches!(class[i], Some(LeafHead::Eof)))
305                {
306                    term.unsatisfiable = true;
307                }
308            }
309            // A shared leaf remains live until no satisfiable conjunction
310            // references it, whether as an include or an exclude.
311            recompute_unreferenced(&terms, &class, &mut unreferenced);
312
313            // Leapfrog bounds advance logical progress before the physical
314            // iterator drains or seeks across the corresponding dead rows.
315            let targets = leaf_skip_targets(&terms, &class, &unreferenced, direction);
316            for (i, target) in targets.iter().enumerate() {
317                if let Some(target) = target {
318                    let (pre, _) = bucket_edges(*target, bucket_size, &range, direction);
319                    front[i] = advance_in_direction(front[i], pre, direction);
320                }
321            }
322
323            // Consume stop frames so they surface. `collapse` attaches this
324            // round's proven-safe floor to scan-limit errors.
325            let mut errors: Vec<LeafStop> = Vec::new();
326            for i in 0..leaf_count {
327                if !unreferenced[i] && matches!(class[i], Some(LeafHead::Error)) {
328                    match leaves[i].next() {
329                        Some(Err(error)) => errors.push(error),
330                        _ => unreachable!("peek classified Error"),
331                    }
332                }
333            }
334
335            let active: Vec<usize> = (0..leaf_count).filter(|&i| !unreferenced[i]).collect();
336            // Natural completion is represented by the caller's terminal
337            // boundary; only progress earned here is emitted.
338            if active.is_empty() {
339                done = true;
340                return None;
341            }
342
343            // The floor is the slowest active leaf's proven-safe position and
344            // therefore the furthest safe merged watermark.
345            let floor_pos = active
346                .iter()
347                .map(|&i| front[i])
348                .reduce(|a, b| bound_in_direction(a, b, direction))
349                .expect("active non-empty");
350            let collapsed = (!errors.is_empty()).then(|| collapse(errors, floor_pos));
351            let scan_limited = matches!(collapsed, Some(ScanStop::ScanLimit { .. }));
352            if !scan_limited && frontier_advanced(progress_frontier, floor_pos, direction) {
353                pending.push_back(Ok(Watermarked::Watermark(floor_pos)));
354                progress_frontier = Some(floor_pos);
355            }
356            if let Some(stop) = collapsed {
357                done = true;
358                pending.push_back(Err(stop));
359                continue;
360            }
361
362            // A target marks a leaf whose logical frontier is ahead of its
363            // physical iterator. Rows before that target are proven dead.
364            let lagging: Vec<usize> = active
365                .iter()
366                .copied()
367                .filter(|&i| targets[i].is_some())
368                .collect();
369            // Preserve the drain count while a leaf remains lagging so a moving
370            // target cannot repeatedly restart its probe allowance.
371            for i in 0..leaf_count {
372                if targets[i].is_none() {
373                    leaves[i].drained = 0;
374                }
375            }
376
377            // Lagging heads cannot participate yet. Select the next physical
378            // bucket from leaves that are ready for evaluation.
379            let eval_bucket = active
380                .iter()
381                .filter(|&&i| targets[i].is_none())
382                .filter_map(|&i| match class[i] {
383                    Some(LeafHead::Bucket(bucket)) => Some(bucket),
384                    _ => None,
385                })
386                .reduce(|a, b| bound_in_direction(a, b, direction))
387                .expect("a least term candidate leaf is not lagging");
388            // Ready leaves may be evaluated strictly before the nearest lagging
389            // target. Equality waits so the lagging leaf joins that snapshot.
390            let lagging_target = lagging
391                .iter()
392                .filter_map(|&i| targets[i])
393                .reduce(|a, b| bound_in_direction(a, b, direction));
394            let evaluate =
395                lagging_target.is_none_or(|target| strictly_before(eval_bucket, target, direction));
396
397            if evaluate {
398                let (_, post) = bucket_edges(eval_bucket, bucket_size, &range, direction);
399                // Consume each ready leaf at this bucket once. Shared terms
400                // reuse its snapshot instead of advancing its iterator twice.
401                let mut snapshot: Vec<Option<RoaringBitmap>> =
402                    (0..leaf_count).map(|_| None).collect();
403                let mut on_floor = vec![false; leaf_count];
404                for i in 0..leaf_count {
405                    if !unreferenced[i]
406                        && targets[i].is_none()
407                        && matches!(class[i], Some(LeafHead::Bucket(b)) if b == eval_bucket)
408                    {
409                        on_floor[i] = true;
410                        front[i] = advance_in_direction(front[i], post, direction);
411                        snapshot[i] = match leaves[i].next() {
412                            Some(Ok((_, bitmap))) => Some(bitmap),
413                            _ => None,
414                        };
415                    }
416                }
417                // Evaluate each conjunction from the shared snapshot, then
418                // union non-empty bitmaps to implement the top-level OR.
419                let mut remaining_refs = count_on_floor_refs(&terms, &on_floor);
420                let mut result: Option<RoaringBitmap> = None;
421                for term in &terms {
422                    if term.unsatisfiable {
423                        continue;
424                    }
425                    let includes = term
426                        .includes
427                        .iter()
428                        .map(|&i| {
429                            take_snapshot_bitmap(&mut snapshot, &mut remaining_refs, &on_floor, i)
430                        })
431                        .collect();
432                    let excludes = term
433                        .excludes
434                        .iter()
435                        .map(|&i| {
436                            take_snapshot_bitmap(&mut snapshot, &mut remaining_refs, &on_floor, i)
437                        })
438                        .collect();
439                    if let Some(bitmap) = eval_term_at_bucket(includes, excludes) {
440                        result = Some(match result {
441                            None => bitmap,
442                            Some(acc) => acc | bitmap,
443                        });
444                    }
445                }
446                if let Some(bitmap) = result {
447                    pending.push_back(Ok(Watermarked::Item((eval_bucket, bitmap))));
448                }
449                if frontier_advanced(progress_frontier, post, direction) {
450                    pending.push_back(Ok(Watermarked::Watermark(post)));
451                    progress_frontier = Some(post);
452                }
453            }
454
455            // Physically catch up lagging leaves only after emitting any safe
456            // earlier bucket, because catch-up itself can exhaust the budget.
457            let seeks_before_catch_up = budget.seeks();
458            for i in lagging {
459                let target = targets[i].expect("lagging leaf has target");
460                loop {
461                    let dead_row = matches!(
462                        leaves[i].peek(),
463                        Some(Ok((bucket, _))) if strictly_before(*bucket, target, direction)
464                    );
465                    if !dead_row {
466                        break;
467                    }
468                    if policy
469                        .drain_probe_rows
470                        .is_some_and(|probe| leaves[i].drained >= u64::from(probe.get()))
471                    {
472                        // A dead row in the peek slot was already charged
473                        // through the budgeted leaf; the seek discards it.
474                        if leaves[i].seek_bucket(target) {
475                            budget.note_discarded();
476                        }
477                        budget.note_seek();
478                        break;
479                    }
480                    // Consuming a dead row keeps using the open scan and
481                    // counts both the charged read and the discard.
482                    let discarded = leaves[i].next();
483                    debug_assert!(matches!(discarded, Some(Ok(_))));
484                    leaves[i].drained += 1;
485                    budget.note_discarded();
486                }
487            }
488            if budget.seeks() > seeks_before_catch_up {
489                budget.note_reopen_step();
490            }
491        }
492    });
493    PacedBucketIter {
494        unpaced,
495        pacer,
496        unpaced_ended: false,
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use std::collections::BTreeMap;
503    use std::sync::Arc;
504
505    use futures::StreamExt;
506
507    use super::*;
508    use crate::bitmap_query::BitmapBucketSource;
509    use crate::bitmap_query::BitmapTerm;
510    use crate::bitmap_query::eval_bitmap_query_bucket_stream;
511    use crate::bitmap_query::eval_bitmap_query_stream;
512    use crate::bitmap_query::flatten_watermarked_buckets;
513    use crate::bitmap_query::test_utils::*;
514
515    /// Collect a marked sequence into a comparable `(bucket_id, bits)` /
516    /// watermark form.
517    fn collect_marked(items: Vec<WatermarkedBucket>) -> Vec<Watermarked<(u64, Vec<u32>)>> {
518        items
519            .into_iter()
520            .map(|r| r.unwrap().map_item(|(b, bm)| (b, bm.iter().collect())))
521            .collect()
522    }
523
524    fn items_only(marked: &[Watermarked<(u64, Vec<u32>)>]) -> Vec<(u64, Vec<u32>)> {
525        marked
526            .iter()
527            .filter_map(|w| match w {
528                Watermarked::Item(it) => Some(it.clone()),
529                Watermarked::Watermark(_) => None,
530            })
531            .collect()
532    }
533
534    #[test]
535    fn eval_bitmap_query_bucket_iter_uses_iterator_source() {
536        let source = TestBucketSource {
537            buckets: Arc::new(BTreeMap::from([
538                (test_key(b"a"), vec![(0, vec![1, 2, 3]), (1, vec![5])]),
539                (test_key(b"b"), vec![(0, vec![2, 3]), (1, vec![5])]),
540                (test_key(b"c"), vec![(0, vec![3])]),
541            ])),
542        };
543        let query = BitmapQuery::new(vec![
544            BitmapTerm::new(vec![include(b"a"), include(b"b"), exclude(b"c")]).unwrap(),
545        ])
546        .unwrap();
547
548        let out = eval_bitmap_query_bucket_iter(
549            source,
550            query,
551            0..200_000,
552            BUCKET_SIZE,
553            ScanDirection::Ascending,
554            1_000_000,
555            SkipPolicy::DRAIN_ONLY,
556            EVERY_WATERMARK,
557            |_| {},
558        )
559        .collect::<Vec<_>>();
560        let out = items_only(&collect_marked(out));
561
562        assert_eq!(out, vec![(0, vec![2]), (1, vec![5])]);
563    }
564
565    /// Two terms share the same include `a`. The iter evaluator must collapse
566    /// them to a single backend scan and distribute its per-bucket bitmap to
567    /// both terms. Mirrors the stream-side
568    /// `shared_include_across_terms_scans_dimension_once` test so the dedup
569    /// invariant is exercised on both evaluators.
570    #[test]
571    fn shared_include_across_terms_scans_dimension_once() {
572        use crate::bitmap_query::test_utils::CountingBucketSource;
573
574        let source = CountingBucketSource::new(BTreeMap::from([
575            (test_key(b"a"), vec![(0, vec![1, 2, 3])]),
576            (test_key(b"b"), vec![(0, vec![1])]),
577            (test_key(b"c"), vec![(0, vec![2])]),
578        ]));
579        let query = BitmapQuery::new(vec![
580            BitmapTerm::new(vec![include(b"a"), include(b"b")]).unwrap(),
581            BitmapTerm::new(vec![include(b"a"), include(b"c")]).unwrap(),
582        ])
583        .unwrap();
584
585        let out = items_only(&collect_marked(
586            eval_bitmap_query_bucket_iter(
587                source.clone(),
588                query,
589                0..200_000,
590                BUCKET_SIZE,
591                ScanDirection::Ascending,
592                1_000_000,
593                SkipPolicy::DRAIN_ONLY,
594                EVERY_WATERMARK,
595                |_| {},
596            )
597            .collect(),
598        ));
599
600        // Bucket 0: term1 = a∩b = {1}; term2 = a∩c = {2}; union = {1, 2}.
601        assert_eq!(out, vec![(0, vec![1, 2])]);
602        assert_eq!(source.scan_count(&test_key(b"a")), 1);
603        assert_eq!(source.scan_count(&test_key(b"b")), 1);
604        assert_eq!(source.scan_count(&test_key(b"c")), 1);
605    }
606
607    /// The iterator evaluator must produce the exact same `Watermarked` sequence
608    /// — items AND progress watermarks — as the stream evaluator for the same
609    /// query, since both share the per-bucket DNF evaluation and floor logic.
610    #[tokio::test]
611    async fn eval_bitmap_query_bucket_iter_matches_stream_for_or_terms() {
612        let source = TestBucketSource {
613            buckets: Arc::new(BTreeMap::from([
614                (
615                    test_key(b"a"),
616                    vec![(0, vec![1, 2, 3]), (1, vec![5, 6]), (2, vec![9])],
617                ),
618                (
619                    test_key(b"b"),
620                    vec![(0, vec![2, 3]), (1, vec![6]), (2, vec![9, 10])],
621                ),
622                (test_key(b"c"), vec![(0, vec![3]), (2, vec![9])]),
623                (test_key(b"d"), vec![(1, vec![1, 8]), (2, vec![7])]),
624                (test_key(b"e"), vec![(1, vec![8])]),
625            ])),
626        };
627        let query = BitmapQuery::new(vec![
628            BitmapTerm::new(vec![include(b"a"), include(b"b"), exclude(b"c")]).unwrap(),
629            BitmapTerm::new(vec![include(b"d"), exclude(b"e")]).unwrap(),
630        ])
631        .unwrap();
632
633        for direction in [ScanDirection::Ascending, ScanDirection::Descending] {
634            for policy in [
635                SkipPolicy::DRAIN_ONLY,
636                SkipPolicy {
637                    drain_probe_rows: std::num::NonZeroU32::new(2),
638                },
639            ] {
640                let stream_out: Vec<_> = eval_bitmap_query_bucket_stream(
641                    source.clone(),
642                    query.clone(),
643                    0..300_000,
644                    BUCKET_SIZE,
645                    direction,
646                    BitmapScanBudget::new(1_000_000),
647                    policy,
648                )
649                .collect()
650                .await;
651                let iter_out: Vec<_> = eval_bitmap_query_bucket_iter(
652                    source.clone(),
653                    query.clone(),
654                    0..300_000,
655                    BUCKET_SIZE,
656                    direction,
657                    1_000_000,
658                    policy,
659                    EVERY_WATERMARK,
660                    |_| {},
661                )
662                .collect();
663
664                assert_eq!(
665                    collect_marked(stream_out),
666                    collect_marked(iter_out),
667                    "iter and stream marked sequences diverged for {direction:?}"
668                );
669            }
670        }
671    }
672
673    /// Parity holds even when buckets are spread far apart (sparse gaps, leaves
674    /// leapfrogging) — the regime where a naive merge could drift between the two
675    /// evaluators.
676    #[tokio::test]
677    async fn eval_bitmap_query_bucket_iter_matches_stream_over_sparse_gaps() {
678        let source = TestBucketSource {
679            buckets: Arc::new(BTreeMap::from([
680                (
681                    test_key(b"a"),
682                    vec![(0, vec![1, 2, 3]), (5, vec![5, 6]), (9, vec![9])],
683                ),
684                (
685                    test_key(b"b"),
686                    vec![(0, vec![2, 3]), (5, vec![6]), (9, vec![9, 10])],
687                ),
688                (test_key(b"c"), vec![(0, vec![3]), (9, vec![9])]),
689                (test_key(b"d"), vec![(3, vec![1, 8]), (7, vec![7])]),
690                (test_key(b"e"), vec![(3, vec![8])]),
691            ])),
692        };
693        let query = BitmapQuery::new(vec![
694            BitmapTerm::new(vec![include(b"a"), include(b"b"), exclude(b"c")]).unwrap(),
695            BitmapTerm::new(vec![include(b"d"), exclude(b"e")]).unwrap(),
696        ])
697        .unwrap();
698
699        for direction in [ScanDirection::Ascending, ScanDirection::Descending] {
700            for policy in [
701                SkipPolicy::DRAIN_ONLY,
702                SkipPolicy {
703                    drain_probe_rows: std::num::NonZeroU32::new(2),
704                },
705            ] {
706                let stream_out: Vec<_> = eval_bitmap_query_bucket_stream(
707                    source.clone(),
708                    query.clone(),
709                    0..(10 * BUCKET_SIZE),
710                    BUCKET_SIZE,
711                    direction,
712                    BitmapScanBudget::new(1_000_000),
713                    policy,
714                )
715                .collect()
716                .await;
717                let iter_out: Vec<_> = eval_bitmap_query_bucket_iter(
718                    source.clone(),
719                    query.clone(),
720                    0..(10 * BUCKET_SIZE),
721                    BUCKET_SIZE,
722                    direction,
723                    1_000_000,
724                    policy,
725                    EVERY_WATERMARK,
726                    |_| {},
727                )
728                .collect();
729
730                assert_eq!(
731                    collect_marked(stream_out),
732                    collect_marked(iter_out),
733                    "iter and stream diverged over sparse gaps for {direction:?}"
734                );
735            }
736        }
737    }
738
739    /// A sparse intersection that matches nothing in a gap still emits earned
740    /// progress, including the last bucket's post edge at the range terminus.
741    #[test]
742    fn intersect_emits_coalesced_watermarks_over_sparse_gap() {
743        let source = TestBucketSource {
744            buckets: Arc::new(BTreeMap::from([
745                (test_key(b"a"), vec![(0, vec![1]), (2, vec![5])]),
746                (test_key(b"b"), vec![(0, vec![1]), (2, vec![9])]),
747            ])),
748        };
749        let query = BitmapQuery::new(vec![
750            BitmapTerm::new(vec![include(b"a"), include(b"b")]).unwrap(),
751        ])
752        .unwrap();
753
754        let marked = collect_marked(
755            eval_bitmap_query_bucket_iter(
756                source,
757                query,
758                0..300_000,
759                BUCKET_SIZE,
760                ScanDirection::Ascending,
761                1_000_000,
762                SkipPolicy::DRAIN_ONLY,
763                EVERY_WATERMARK,
764                |_| {},
765            )
766            .collect(),
767        );
768
769        // Only bucket 0 intersects (member 1); bucket 2 disjoint -> dropped.
770        assert_eq!(items_only(&marked), vec![(0, vec![1])]);
771
772        // Watermarks must be non-decreasing; the last bucket earns the terminus.
773        let watermarks: Vec<u64> = marked
774            .iter()
775            .filter_map(|w| match w {
776                Watermarked::Watermark(p) => Some(*p),
777                Watermarked::Item(_) => None,
778            })
779            .collect();
780        assert!(
781            watermarks.windows(2).all(|w| w[0] <= w[1]),
782            "ascending watermarks must be non-decreasing: {watermarks:?}"
783        );
784        assert_eq!(
785            watermarks.last().copied(),
786            Some(300_000),
787            "last bucket's post edge must reach the range terminus"
788        );
789    }
790
791    #[test]
792    fn descending_request_floor_is_not_emitted_as_progress() {
793        let source = TestBucketSource {
794            buckets: Arc::new(BTreeMap::from([(
795                test_key(b"a"),
796                vec![(2, vec![0, 50_000])],
797            )])),
798        };
799        let query = BitmapQuery::new(vec![BitmapTerm::new(vec![include(b"a")]).unwrap()]).unwrap();
800
801        let marked = collect_marked(
802            eval_bitmap_query_bucket_iter(
803                source,
804                query,
805                50..(2 * BUCKET_SIZE + 50_001),
806                BUCKET_SIZE,
807                ScanDirection::Descending,
808                1_000_000,
809                SkipPolicy::DRAIN_ONLY,
810                EVERY_WATERMARK,
811                |_| {},
812            )
813            .collect(),
814        );
815
816        assert_eq!(
817            marked,
818            vec![
819                Watermarked::Item((2, vec![0, 50_000])),
820                Watermarked::Watermark(2 * BUCKET_SIZE),
821            ],
822        );
823    }
824
825    #[test]
826    fn natural_completion_omits_terminus_but_retains_earned_progress() {
827        let source = TestBucketSource {
828            buckets: Arc::new(BTreeMap::from([(test_key(b"a"), vec![(3, vec![5])])])),
829        };
830        let query = BitmapQuery::new(vec![BitmapTerm::new(vec![include(b"a")]).unwrap()]).unwrap();
831
832        let marked = collect_marked(
833            eval_bitmap_query_bucket_iter(
834                source,
835                query,
836                0..(5 * BUCKET_SIZE),
837                BUCKET_SIZE,
838                ScanDirection::Ascending,
839                1_000_000,
840                SkipPolicy::DRAIN_ONLY,
841                EVERY_WATERMARK,
842                |_| {},
843            )
844            .collect(),
845        );
846
847        assert_eq!(
848            marked,
849            vec![
850                Watermarked::Watermark(3 * BUCKET_SIZE),
851                Watermarked::Item((3, vec![5])),
852                Watermarked::Watermark(4 * BUCKET_SIZE),
853            ],
854        );
855    }
856
857    /// An unanchored term (`NOT x`, anchored on the synthesized universe leaf)
858    /// emits the complement at exclude-occupied buckets, full bitmaps at gap
859    /// buckets, and keeps emitting full buckets after the exclude leaf EOFs.
860    #[test]
861    fn unanchored_term_emits_complement_over_gaps_and_past_exclude_eof() {
862        let source = TestBucketSource {
863            buckets: Arc::new(BTreeMap::from([(
864                test_key(b"x"),
865                vec![(0, vec![1, 2]), (2, vec![5])],
866            )])),
867        };
868        let query = BitmapQuery::new(vec![
869            BitmapTerm::new(vec![include_universe(), exclude(b"x")]).unwrap(),
870        ])
871        .unwrap();
872
873        let items: Vec<(u64, RoaringBitmap)> = eval_bitmap_query_bucket_iter(
874            source,
875            query,
876            0..(4 * BUCKET_SIZE),
877            BUCKET_SIZE,
878            ScanDirection::Ascending,
879            1_000_000,
880            SkipPolicy::DRAIN_ONLY,
881            EVERY_WATERMARK,
882            |_| {},
883        )
884        .filter_map(|r| match r.unwrap() {
885            Watermarked::Item(it) => Some(it),
886            Watermarked::Watermark(_) => None,
887        })
888        .collect();
889
890        let complement = |bits: &[u32]| {
891            let mut bm = full_bucket();
892            for &b in bits {
893                bm.remove(b);
894            }
895            bm
896        };
897        let expected = vec![
898            (0, complement(&[1, 2])),
899            (1, full_bucket()),
900            (2, complement(&[5])),
901            (3, full_bucket()),
902        ];
903        assert_eq!(items, expected);
904    }
905
906    /// Iter/stream parity for a mixed DNF with an unanchored term, in both
907    /// directions — the universe leaf forces dense bucket coverage while the
908    /// anchored term stays sparse.
909    #[tokio::test]
910    async fn eval_bitmap_query_bucket_iter_matches_stream_for_unanchored_terms() {
911        let source = TestBucketSource {
912            buckets: Arc::new(BTreeMap::from([
913                (test_key(b"a"), vec![(1, vec![7])]),
914                (test_key(b"x"), vec![(0, vec![1, 2]), (3, vec![5])]),
915            ])),
916        };
917        let query = BitmapQuery::new(vec![
918            BitmapTerm::new(vec![include(b"a")]).unwrap(),
919            BitmapTerm::new(vec![include_universe(), exclude(b"x")]).unwrap(),
920        ])
921        .unwrap();
922
923        for direction in [ScanDirection::Ascending, ScanDirection::Descending] {
924            for policy in [
925                SkipPolicy::DRAIN_ONLY,
926                SkipPolicy {
927                    drain_probe_rows: std::num::NonZeroU32::new(2),
928                },
929            ] {
930                let stream_out: Vec<_> = eval_bitmap_query_bucket_stream(
931                    source.clone(),
932                    query.clone(),
933                    0..(5 * BUCKET_SIZE),
934                    BUCKET_SIZE,
935                    direction,
936                    BitmapScanBudget::new(1_000_000),
937                    policy,
938                )
939                .collect()
940                .await;
941                let iter_out: Vec<_> = eval_bitmap_query_bucket_iter(
942                    source.clone(),
943                    query.clone(),
944                    0..(5 * BUCKET_SIZE),
945                    BUCKET_SIZE,
946                    direction,
947                    1_000_000,
948                    policy,
949                    EVERY_WATERMARK,
950                    |_| {},
951                )
952                .collect();
953
954                assert_eq!(
955                    collect_marked(stream_out),
956                    collect_marked(iter_out),
957                    "iter and stream diverged on unanchored terms for {direction:?}"
958                );
959            }
960        }
961    }
962
963    /// Budget exhaustion mid-dense-scan bundles the merged floor in the
964    /// terminal, and resuming from that frontier covers every remaining bucket
965    /// exactly once without relying on a terminal-round progress beacon.
966    #[tokio::test]
967    async fn unanchored_budget_exhaustion_resumes_at_terminal_frontier() {
968        let source = TestBucketSource {
969            buckets: Arc::new(BTreeMap::new()),
970        };
971        let query = BitmapQuery::new(vec![
972            BitmapTerm::new(vec![include_universe(), exclude(b"x")]).unwrap(),
973        ])
974        .unwrap();
975
976        let first: Vec<_> = eval_bitmap_query_bucket_stream(
977            source.clone(),
978            query.clone(),
979            0..(10 * BUCKET_SIZE),
980            BUCKET_SIZE,
981            ScanDirection::Ascending,
982            BitmapScanBudget::new(3),
983            SkipPolicy::DRAIN_ONLY,
984        )
985        .collect()
986        .await;
987
988        let mut covered: Vec<u64> = Vec::new();
989        let mut resume_from = None;
990        let mut limit_hit = false;
991        for item in first {
992            match item {
993                Ok(Watermarked::Item((bucket, bitmap))) => {
994                    assert_eq!(bitmap, full_bucket());
995                    covered.push(bucket);
996                }
997                Ok(Watermarked::Watermark(_)) => {}
998                Err(ScanStop::ScanLimit { scan_frontier }) => {
999                    resume_from = Some(scan_frontier);
1000                    limit_hit = true;
1001                }
1002                Err(other) => panic!("expected ScanLimit, got {other:?}"),
1003            }
1004        }
1005        assert!(limit_hit, "3-bucket budget cannot cover 10 dense buckets");
1006        assert_eq!(covered, vec![0, 1, 2]);
1007        let resume_from = resume_from.expect("ScanLimit must carry a resume frontier");
1008        assert_eq!(resume_from, 3 * BUCKET_SIZE);
1009
1010        let resumed: Vec<_> = eval_bitmap_query_bucket_stream(
1011            source,
1012            query,
1013            resume_from..(10 * BUCKET_SIZE),
1014            BUCKET_SIZE,
1015            ScanDirection::Ascending,
1016            BitmapScanBudget::new(1_000_000),
1017            SkipPolicy::DRAIN_ONLY,
1018        )
1019        .collect()
1020        .await;
1021        for item in resumed {
1022            if let Watermarked::Item((bucket, _)) = item.unwrap() {
1023                covered.push(bucket);
1024            }
1025        }
1026        assert_eq!(covered, (0..10).collect::<Vec<_>>());
1027    }
1028    #[tokio::test]
1029    async fn iter_seeks_lagging_leaf_natively() {
1030        let source = CountingBucketSource::new(BTreeMap::from([
1031            (test_key(b"a"), vec![(0, vec![1]), (50, vec![1])]),
1032            (
1033                test_key(b"b"),
1034                (0..=50).map(|bucket| (bucket, vec![1])).collect(),
1035            ),
1036        ]));
1037        let query = BitmapQuery::new(vec![
1038            BitmapTerm::new(vec![include(b"a"), include(b"b")]).unwrap(),
1039        ])
1040        .unwrap();
1041        let policy = SkipPolicy {
1042            drain_probe_rows: std::num::NonZeroU32::new(2),
1043        };
1044
1045        let stream_out = eval_bitmap_query_bucket_stream(
1046            source.clone(),
1047            query.clone(),
1048            0..(51 * BUCKET_SIZE),
1049            BUCKET_SIZE,
1050            ScanDirection::Ascending,
1051            BitmapScanBudget::new(1_000),
1052            policy,
1053        )
1054        .collect()
1055        .await;
1056        let iter_out = eval_bitmap_query_bucket_iter(
1057            source.clone(),
1058            query,
1059            0..(51 * BUCKET_SIZE),
1060            BUCKET_SIZE,
1061            ScanDirection::Ascending,
1062            1_000,
1063            policy,
1064            EVERY_WATERMARK,
1065            |_| {},
1066        )
1067        .collect();
1068
1069        assert_eq!(collect_marked(stream_out), collect_marked(iter_out));
1070        assert_eq!(source.seek_count(&test_key(b"b")), 1);
1071    }
1072
1073    /// One evaluator output in comparable form: an item as `(bucket_id, bits)`,
1074    /// a watermark, or a scan-limit stop as `Err(scan_frontier)`.
1075    type ObservedOutput = Result<Watermarked<(u64, Vec<u32>)>, u64>;
1076
1077    /// Comparable form of an evaluator's output; see [`ObservedOutput`].
1078    fn observe(outputs: Vec<WatermarkedBucket>) -> Vec<ObservedOutput> {
1079        outputs
1080            .into_iter()
1081            .map(|output| match output {
1082                Ok(marked) => {
1083                    Ok(marked.map_item(|(bucket, bitmap)| (bucket, bitmap.iter().collect())))
1084                }
1085                Err(ScanStop::ScanLimit { scan_frontier }) => Err(scan_frontier),
1086                Err(other) => panic!("expected only scan-limit stops, got {other:?}"),
1087            })
1088            .collect()
1089    }
1090
1091    /// Run both evaluators with every budget in `budgets`, in both directions
1092    /// and under both skip policies, and require identical items, watermarks
1093    /// and scan-limit frontiers. Also requires that some runs stop on the
1094    /// budget and some complete, so the comparison covers exhaustion.
1095    async fn assert_iter_matches_stream_under_budgets(
1096        source: TestBucketSource,
1097        query: BitmapQuery,
1098        range: Range<u64>,
1099        budgets: std::ops::RangeInclusive<u64>,
1100    ) {
1101        let mut stopped = 0;
1102        let mut completed = 0;
1103        for budget in budgets {
1104            for direction in [ScanDirection::Ascending, ScanDirection::Descending] {
1105                for policy in [
1106                    SkipPolicy::DRAIN_ONLY,
1107                    SkipPolicy {
1108                        drain_probe_rows: std::num::NonZeroU32::new(2),
1109                    },
1110                ] {
1111                    let stream_out = observe(
1112                        eval_bitmap_query_bucket_stream(
1113                            source.clone(),
1114                            query.clone(),
1115                            range.clone(),
1116                            BUCKET_SIZE,
1117                            direction,
1118                            BitmapScanBudget::new(budget),
1119                            policy,
1120                        )
1121                        .collect()
1122                        .await,
1123                    );
1124                    let iter_out = observe(
1125                        eval_bitmap_query_bucket_iter(
1126                            source.clone(),
1127                            query.clone(),
1128                            range.clone(),
1129                            BUCKET_SIZE,
1130                            direction,
1131                            budget,
1132                            policy,
1133                            EVERY_WATERMARK,
1134                            |_| {},
1135                        )
1136                        .collect(),
1137                    );
1138
1139                    assert_eq!(
1140                        stream_out, iter_out,
1141                        "iter and stream diverged with budget {budget}, {direction:?}, {policy:?}"
1142                    );
1143                    if matches!(iter_out.last(), Some(Err(_))) {
1144                        stopped += 1;
1145                    } else {
1146                        completed += 1;
1147                    }
1148                }
1149            }
1150        }
1151        assert!(stopped > 0, "no budget in the sweep exhausted the scan");
1152        assert!(completed > 0, "no budget in the sweep completed the scan");
1153    }
1154
1155    /// `a AND b` where `b` is dense and `a` jumps from bucket 0 to 50, so
1156    /// catching `b` up drains (and, with a probe policy, seeks past) dead rows.
1157    fn skewed_and_source() -> TestBucketSource {
1158        TestBucketSource {
1159            buckets: Arc::new(BTreeMap::from([
1160                (test_key(b"a"), vec![(0, vec![1]), (50, vec![1])]),
1161                (
1162                    test_key(b"b"),
1163                    (0..=50).map(|bucket| (bucket, vec![1])).collect(),
1164                ),
1165            ])),
1166        }
1167    }
1168
1169    fn skewed_and_query() -> BitmapQuery {
1170        BitmapQuery::new(vec![
1171            BitmapTerm::new(vec![include(b"a"), include(b"b")]).unwrap(),
1172        ])
1173        .unwrap()
1174    }
1175
1176    /// Budget exhaustion during gap catch-up (drained or seeked dead rows are
1177    /// charged) stops both evaluators at the same output and frontier.
1178    #[tokio::test]
1179    async fn small_budget_iter_matches_stream_during_gap_catch_up() {
1180        assert_iter_matches_stream_under_budgets(
1181            skewed_and_source(),
1182            skewed_and_query(),
1183            0..(51 * BUCKET_SIZE),
1184            1..=60,
1185        )
1186        .await;
1187    }
1188
1189    /// Budget exhaustion in a DNF over sparse gaps stops both evaluators at
1190    /// the same output and frontier.
1191    #[tokio::test]
1192    async fn small_budget_iter_matches_stream_for_or_terms_over_sparse_gaps() {
1193        let source = TestBucketSource {
1194            buckets: Arc::new(BTreeMap::from([
1195                (
1196                    test_key(b"a"),
1197                    vec![(0, vec![1, 2, 3]), (5, vec![5, 6]), (9, vec![9])],
1198                ),
1199                (
1200                    test_key(b"b"),
1201                    vec![(0, vec![2, 3]), (5, vec![6]), (9, vec![9, 10])],
1202                ),
1203                (test_key(b"c"), vec![(0, vec![3]), (9, vec![9])]),
1204                (test_key(b"d"), vec![(3, vec![1, 8]), (7, vec![7])]),
1205                (test_key(b"e"), vec![(3, vec![8])]),
1206            ])),
1207        };
1208        let query = BitmapQuery::new(vec![
1209            BitmapTerm::new(vec![include(b"a"), include(b"b"), exclude(b"c")]).unwrap(),
1210            BitmapTerm::new(vec![include(b"d"), exclude(b"e")]).unwrap(),
1211        ])
1212        .unwrap();
1213
1214        assert_iter_matches_stream_under_budgets(source, query, 0..(10 * BUCKET_SIZE), 1..=20)
1215            .await;
1216    }
1217
1218    /// Tightest budgets: `(a AND b) OR c` with three leaves. Every leaf's first
1219    /// bucket is reserved, so even a budget at or below the leaf count lets
1220    /// `c` report bucket 40 and `b` pin the frontier at bucket 50 before
1221    /// catch-up of `a` exhausts the budget.
1222    #[test]
1223    fn budget_at_or_below_leaf_count_still_reports_every_first_position() {
1224        let source = TestBucketSource {
1225            buckets: Arc::new(BTreeMap::from([
1226                (
1227                    test_key(b"a"),
1228                    (0..=4).map(|bucket| (bucket, vec![1])).collect(),
1229                ),
1230                (test_key(b"b"), vec![(50, vec![1])]),
1231                (test_key(b"c"), vec![(40, vec![7])]),
1232            ])),
1233        };
1234        let query = BitmapQuery::new(vec![
1235            BitmapTerm::new(vec![include(b"a"), include(b"b")]).unwrap(),
1236            BitmapTerm::new(vec![include(b"c")]).unwrap(),
1237        ])
1238        .unwrap();
1239
1240        for budget in 1..=3 {
1241            let out = observe(
1242                eval_bitmap_query_bucket_iter(
1243                    source.clone(),
1244                    query.clone(),
1245                    0..(60 * BUCKET_SIZE),
1246                    BUCKET_SIZE,
1247                    ScanDirection::Ascending,
1248                    budget,
1249                    SkipPolicy::DRAIN_ONLY,
1250                    EVERY_WATERMARK,
1251                    |_| {},
1252                )
1253                .collect(),
1254            );
1255
1256            assert_eq!(
1257                out,
1258                vec![
1259                    Ok(Watermarked::Watermark(40 * BUCKET_SIZE)),
1260                    Ok(Watermarked::Item((40, vec![7]))),
1261                    Ok(Watermarked::Watermark(41 * BUCKET_SIZE)),
1262                    Err(50 * BUCKET_SIZE),
1263                ],
1264                "budget {budget}"
1265            );
1266        }
1267    }
1268
1269    /// Charged, discarded and seek counts match the stream evaluator for a
1270    /// scan that both drains and seeks across a dead gap.
1271    #[tokio::test]
1272    async fn metrics_match_stream_when_crossing_gap() {
1273        let policy = SkipPolicy {
1274            drain_probe_rows: std::num::NonZeroU32::new(2),
1275        };
1276        for direction in [ScanDirection::Ascending, ScanDirection::Descending] {
1277            let (stream_tx, stream_rx) = std::sync::mpsc::channel();
1278            let _: Vec<_> = eval_bitmap_query_stream(
1279                skewed_and_source(),
1280                skewed_and_query(),
1281                0..(51 * BUCKET_SIZE),
1282                BUCKET_SIZE,
1283                direction,
1284                1_000,
1285                policy,
1286                EVERY_WATERMARK,
1287                move |observed| stream_tx.send(observed).unwrap(),
1288            )
1289            .collect()
1290            .await;
1291            let (iter_tx, iter_rx) = std::sync::mpsc::channel();
1292            let _: Vec<_> = eval_bitmap_query_bucket_iter(
1293                skewed_and_source(),
1294                skewed_and_query(),
1295                0..(51 * BUCKET_SIZE),
1296                BUCKET_SIZE,
1297                direction,
1298                1_000,
1299                policy,
1300                EVERY_WATERMARK,
1301                move |observed| iter_tx.send(observed).unwrap(),
1302            )
1303            .collect();
1304
1305            let iter_metrics = iter_rx.recv().expect("iter metrics reported");
1306            assert_eq!(
1307                iter_metrics,
1308                stream_rx.recv().expect("stream metrics reported"),
1309                "{direction:?}"
1310            );
1311            assert_eq!(
1312                iter_metrics,
1313                BitmapScanMetrics {
1314                    buckets_evaluated: 7,
1315                    buckets_discarded: 3,
1316                    leaf_seeks: 1,
1317                },
1318                "{direction:?}"
1319            );
1320        }
1321    }
1322
1323    /// `on_metrics` fires exactly once, at drop: not while the iterator is
1324    /// alive, and also when it is dropped before or in the middle of a scan.
1325    #[test]
1326    fn metrics_fire_once_when_dropped_mid_scan() {
1327        fn scan(
1328            on_metrics: impl FnOnce(BitmapScanMetrics) + 'static,
1329        ) -> impl Iterator<Item = WatermarkedBucket> {
1330            eval_bitmap_query_bucket_iter(
1331                skewed_and_source(),
1332                skewed_and_query(),
1333                0..(51 * BUCKET_SIZE),
1334                BUCKET_SIZE,
1335                ScanDirection::Ascending,
1336                1_000,
1337                SkipPolicy {
1338                    drain_probe_rows: std::num::NonZeroU32::new(2),
1339                },
1340                EVERY_WATERMARK,
1341                on_metrics,
1342            )
1343        }
1344
1345        let (unpolled_tx, unpolled_rx) = std::sync::mpsc::channel();
1346        let unpolled = scan(move |observed| unpolled_tx.send(observed).unwrap());
1347        assert!(unpolled_rx.try_recv().is_err(), "fired before drop");
1348        drop(unpolled);
1349        assert_eq!(
1350            unpolled_rx.try_recv().expect("fired on drop"),
1351            BitmapScanMetrics {
1352                buckets_evaluated: 0,
1353                buckets_discarded: 0,
1354                leaf_seeks: 0,
1355            }
1356        );
1357        assert!(
1358            matches!(
1359                unpolled_rx.try_recv(),
1360                Err(std::sync::mpsc::TryRecvError::Disconnected)
1361            ),
1362            "fired more than once"
1363        );
1364
1365        let (partial_tx, partial_rx) = std::sync::mpsc::channel();
1366        let mut partial = scan(move |observed| partial_tx.send(observed).unwrap());
1367        // The first round charges both leaves' first buckets and yields the
1368        // bucket-0 match; the gap after it is never reached.
1369        assert!(matches!(
1370            partial.next(),
1371            Some(Ok(Watermarked::Item((0, _))))
1372        ));
1373        assert!(partial_rx.try_recv().is_err(), "fired before drop");
1374        drop(partial);
1375        assert_eq!(
1376            partial_rx.try_recv().expect("fired on drop"),
1377            BitmapScanMetrics {
1378                buckets_evaluated: 2,
1379                buckets_discarded: 0,
1380                leaf_seeks: 0,
1381            }
1382        );
1383        assert!(
1384            matches!(
1385                partial_rx.try_recv(),
1386                Err(std::sync::mpsc::TryRecvError::Disconnected)
1387            ),
1388            "fired more than once"
1389        );
1390    }
1391
1392    /// Absent-dimension semantics: an include whose key has no rows at all annihilates its
1393    /// conjunction (`∩ ∅ = ∅`). Pinned explicitly because this shape only arises when a queried key
1394    /// was never written (e.g. a sender with no transactions), which live-cluster tests never
1395    /// exercise.
1396    #[test]
1397    fn absent_include_annihilates_term() {
1398        let source = TestBucketSource {
1399            buckets: Arc::new(BTreeMap::from([(test_key(b"a"), vec![(0, vec![1, 2])])])),
1400        };
1401        let query =
1402            BitmapQuery::new(vec![BitmapTerm::new(vec![include(b"ghost")]).unwrap()]).unwrap();
1403
1404        let out = eval_bitmap_query_bucket_iter(
1405            source,
1406            query,
1407            0..200_000,
1408            BUCKET_SIZE,
1409            ScanDirection::Ascending,
1410            1_000_000,
1411            SkipPolicy::DRAIN_ONLY,
1412            EVERY_WATERMARK,
1413            |_| {},
1414        )
1415        .collect::<Vec<_>>();
1416        let items = items_only(&collect_marked(out));
1417
1418        assert!(
1419            items.is_empty(),
1420            "absent include must annihilate: {items:?}"
1421        );
1422    }
1423
1424    /// A present include cannot rescue a conjunction whose other include is absent — the
1425    /// intersection is still empty.
1426    #[test]
1427    fn absent_include_annihilates_term_despite_present_include() {
1428        let source = TestBucketSource {
1429            buckets: Arc::new(BTreeMap::from([(test_key(b"a"), vec![(0, vec![1, 2])])])),
1430        };
1431        let query = BitmapQuery::new(vec![
1432            BitmapTerm::new(vec![include(b"a"), include(b"ghost")]).unwrap(),
1433        ])
1434        .unwrap();
1435
1436        let out = eval_bitmap_query_bucket_iter(
1437            source,
1438            query,
1439            0..200_000,
1440            BUCKET_SIZE,
1441            ScanDirection::Ascending,
1442            1_000_000,
1443            SkipPolicy::DRAIN_ONLY,
1444            EVERY_WATERMARK,
1445            |_| {},
1446        )
1447        .collect::<Vec<_>>();
1448        let items = items_only(&collect_marked(out));
1449
1450        assert!(
1451            items.is_empty(),
1452            "absent include must annihilate: {items:?}"
1453        );
1454    }
1455
1456    /// An exclude whose key has no rows subtracts nothing (`∖ ∅`): the present include's matches
1457    /// pass through untouched.
1458    #[test]
1459    fn absent_exclude_is_noop() {
1460        let source = TestBucketSource {
1461            buckets: Arc::new(BTreeMap::from([(test_key(b"a"), vec![(0, vec![1, 2])])])),
1462        };
1463        let query = BitmapQuery::new(vec![
1464            BitmapTerm::new(vec![include(b"a"), exclude(b"ghost")]).unwrap(),
1465        ])
1466        .unwrap();
1467
1468        let out = eval_bitmap_query_bucket_iter(
1469            source,
1470            query,
1471            0..200_000,
1472            BUCKET_SIZE,
1473            ScanDirection::Ascending,
1474            1_000_000,
1475            SkipPolicy::DRAIN_ONLY,
1476            EVERY_WATERMARK,
1477            |_| {},
1478        )
1479        .collect::<Vec<_>>();
1480
1481        assert_eq!(items_only(&collect_marked(out)), vec![(0, vec![1, 2])]);
1482    }
1483
1484    /// Run both public evaluators over `source` with the same budget and
1485    /// pacing, in both directions, and require identical paced output: the
1486    /// iterator's buckets are flattened exactly as the stream evaluator
1487    /// flattens its own. Returns how many runs ended on a scan-limit stop.
1488    async fn assert_paced_iter_matches_paced_stream<S>(source: S) -> usize
1489    where
1490        S: BitmapBucketSource + for<'a> BitmapBucketIteratorSource<'a>,
1491    {
1492        let range = 0..(10 * BUCKET_SIZE);
1493        let mut scan_limited = 0;
1494        for direction in [ScanDirection::Ascending, ScanDirection::Descending] {
1495            for budget in [7, 1_000_000] {
1496                for pacing in [
1497                    charged_buckets(1, false),
1498                    charged_buckets(3, true),
1499                    charged_buckets(5, false),
1500                    charged_buckets(5, true),
1501                ] {
1502                    let iter_buckets: Vec<_> = eval_bitmap_query_bucket_iter(
1503                        source.clone(),
1504                        parallel_leaves_query(),
1505                        range.clone(),
1506                        BUCKET_SIZE,
1507                        direction,
1508                        budget,
1509                        SkipPolicy::DRAIN_ONLY,
1510                        pacing,
1511                        |_| {},
1512                    )
1513                    .collect();
1514                    let iter_members: Vec<_> = flatten_watermarked_buckets(
1515                        futures::stream::iter(iter_buckets),
1516                        range.clone(),
1517                        BUCKET_SIZE,
1518                        direction,
1519                    )
1520                    .collect()
1521                    .await;
1522                    let stream_members: Vec<_> = eval_bitmap_query_stream(
1523                        source.clone(),
1524                        parallel_leaves_query(),
1525                        range.clone(),
1526                        BUCKET_SIZE,
1527                        direction,
1528                        budget,
1529                        SkipPolicy::DRAIN_ONLY,
1530                        pacing,
1531                        |_| {},
1532                    )
1533                    .collect()
1534                    .await;
1535
1536                    let iter_members = observe_member_outputs(iter_members);
1537                    assert_eq!(
1538                        iter_members,
1539                        observe_member_outputs(stream_members),
1540                        "paced iter and stream diverged with budget {budget}, {direction:?}, \
1541                         {pacing:?}"
1542                    );
1543                    if matches!(iter_members.last(), Some(Err(ObservedStop::ScanLimit(_)))) {
1544                        scan_limited += 1;
1545                    }
1546                }
1547            }
1548        }
1549        scan_limited
1550    }
1551
1552    /// Paced output of the iterator evaluator matches the stream evaluator's
1553    /// for item, watermark, scan-limit, fault and clean-end sequences.
1554    #[tokio::test]
1555    async fn paced_iter_matches_paced_stream() {
1556        let scan_limited =
1557            assert_paced_iter_matches_paced_stream(parallel_leaves_source(10, &[3, 6])).await;
1558        assert!(scan_limited > 0, "no run exhausted the budget");
1559        assert_paced_iter_matches_paced_stream(StoppingBucketSource {
1560            inner: parallel_leaves_source(10, &[3]),
1561            stop_key: test_key(b"a"),
1562            rows_before_stop: 5,
1563            stop: || LeafStop::Fault(anyhow::anyhow!("leaf storage fault")),
1564        })
1565        .await;
1566    }
1567
1568    /// The two public evaluators.
1569    #[derive(Clone, Copy, Debug)]
1570    enum Evaluator {
1571        Iter,
1572        Stream,
1573    }
1574
1575    /// The scan work [`ScanWorkCountingSource`] had counted when an output
1576    /// arrived.
1577    #[derive(Clone, Copy, Debug)]
1578    struct ScanCounts {
1579        rows: u64,
1580        reopens: u64,
1581    }
1582
1583    impl ScanCounts {
1584        /// Scan work as [`WatermarkPacing`] counts it under `policy`, for
1585        /// scans whose catch-up steps reopen at most one leaf each, so every
1586        /// reopen is its own step.
1587        fn work(self, policy: SkipPolicy) -> u64 {
1588            self.rows + policy.reopen_cost_rows() * self.reopens
1589        }
1590    }
1591
1592    /// One evaluation's member-level outputs, each paired with the scan work
1593    /// counted when it arrived, and the evaluation's metrics.
1594    struct ChargedRun {
1595        outputs: Vec<(ObservedMemberOutput, ScanCounts)>,
1596        metrics: BitmapScanMetrics,
1597    }
1598
1599    impl ChargedRun {
1600        fn member_outputs(&self) -> Vec<ObservedMemberOutput> {
1601            self.outputs
1602                .iter()
1603                .map(|(output, _)| output.clone())
1604                .collect()
1605        }
1606
1607        fn items(&self) -> Vec<u64> {
1608            self.outputs
1609                .iter()
1610                .filter_map(|(output, _)| match output {
1611                    Ok(Watermarked::Item(member)) => Some(*member),
1612                    _ => None,
1613                })
1614                .collect()
1615        }
1616
1617        fn watermarks(&self) -> Vec<u64> {
1618            self.outputs
1619                .iter()
1620                .filter_map(|(output, _)| match output {
1621                    Ok(Watermarked::Watermark(position)) => Some(*position),
1622                    _ => None,
1623                })
1624                .collect()
1625        }
1626    }
1627
1628    /// Run `evaluator` over `source` with a budget the scan never exhausts,
1629    /// pairing every member-level output with the scan work counted when it
1630    /// arrived. The iterator evaluator's buckets are flattened one output at
1631    /// a time, exactly as the stream evaluator flattens its own.
1632    async fn charged_member_run(
1633        evaluator: Evaluator,
1634        source: TestBucketSource,
1635        query: BitmapQuery,
1636        range: Range<u64>,
1637        direction: ScanDirection,
1638        policy: SkipPolicy,
1639        pacing: WatermarkPacing,
1640    ) -> ChargedRun {
1641        let source = ScanWorkCountingSource::new(source);
1642        let counts = |source: &ScanWorkCountingSource| ScanCounts {
1643            rows: source.rows_yielded(),
1644            reopens: source.reopens(),
1645        };
1646        let (metrics_tx, metrics_rx) = std::sync::mpsc::channel();
1647        let on_metrics = move |metrics: BitmapScanMetrics| metrics_tx.send(metrics).unwrap();
1648        let mut outputs = Vec::new();
1649        match evaluator {
1650            Evaluator::Iter => {
1651                let bucket_outputs = eval_bitmap_query_bucket_iter(
1652                    source.clone(),
1653                    query,
1654                    range.clone(),
1655                    BUCKET_SIZE,
1656                    direction,
1657                    1_000_000,
1658                    policy,
1659                    pacing,
1660                    on_metrics,
1661                );
1662                for bucket_output in bucket_outputs {
1663                    let counted = counts(&source);
1664                    let members: Vec<_> = flatten_watermarked_buckets(
1665                        futures::stream::iter([bucket_output]),
1666                        range.clone(),
1667                        BUCKET_SIZE,
1668                        direction,
1669                    )
1670                    .collect()
1671                    .await;
1672                    outputs.extend(
1673                        observe_member_outputs(members)
1674                            .into_iter()
1675                            .map(|output| (output, counted)),
1676                    );
1677                }
1678            }
1679            Evaluator::Stream => {
1680                let mut member_outputs = eval_bitmap_query_stream(
1681                    source.clone(),
1682                    query,
1683                    range,
1684                    BUCKET_SIZE,
1685                    direction,
1686                    1_000_000,
1687                    policy,
1688                    pacing,
1689                    on_metrics,
1690                );
1691                while let Some(output) = member_outputs.next().await {
1692                    let counted = counts(&source);
1693                    outputs.extend(
1694                        observe_member_outputs(vec![output])
1695                            .into_iter()
1696                            .map(|output| (output, counted)),
1697                    );
1698                }
1699            }
1700        }
1701        let metrics = metrics_rx.recv().unwrap();
1702        assert_eq!(
1703            metrics.buckets_evaluated,
1704            source.rows_yielded(),
1705            "{evaluator:?}: every yielded row must be charged"
1706        );
1707        assert_eq!(
1708            metrics.leaf_seeks,
1709            source.reopens(),
1710            "{evaluator:?}: every reopen must be counted"
1711        );
1712        ChargedRun { outputs, metrics }
1713    }
1714
1715    fn watermark_at_bucket(bucket: u64) -> ObservedMemberOutput {
1716        Ok(Watermarked::Watermark(bucket * BUCKET_SIZE))
1717    }
1718
1719    /// An item of an edge bucket whose matches all lie outside the scan range
1720    /// trims to nothing, so it is not an emission: the scan's first
1721    /// member-level output is the first unpaced watermark, emitted at once,
1722    /// and later watermarks are paced from it. Ascending, bucket 0's only
1723    /// match is member 1 and the range starts at 2; descending, bucket 9's
1724    /// only match is `9 * BUCKET_SIZE + 1` and the range ends just before it.
1725    /// Each bucket charges two rows, so with five rows between emissions every
1726    /// third watermark is emitted. Counting the trimmed item as an emission
1727    /// holds the first watermark and starts the output three buckets late.
1728    #[tokio::test]
1729    async fn edge_item_without_in_range_members_does_not_delay_watermarks() {
1730        let cases = [
1731            (
1732                ScanDirection::Ascending,
1733                parallel_leaves_source(10, &[0]),
1734                2..(10 * BUCKET_SIZE),
1735                [1, 4, 7, 10],
1736            ),
1737            (
1738                ScanDirection::Descending,
1739                parallel_leaves_source(10, &[9]),
1740                0..(9 * BUCKET_SIZE + 1),
1741                [9, 6, 3, 0],
1742            ),
1743        ];
1744        for evaluator in [Evaluator::Iter, Evaluator::Stream] {
1745            for (direction, source, range, emitted_buckets) in cases.clone() {
1746                let context = format!("{evaluator:?}, {direction:?}");
1747                let expected: Vec<ObservedMemberOutput> = emitted_buckets
1748                    .into_iter()
1749                    .map(watermark_at_bucket)
1750                    .collect();
1751                let unpaced = charged_member_run(
1752                    evaluator,
1753                    source.clone(),
1754                    parallel_leaves_query(),
1755                    range.clone(),
1756                    direction,
1757                    SkipPolicy::DRAIN_ONLY,
1758                    EVERY_WATERMARK,
1759                )
1760                .await;
1761                assert_eq!(
1762                    unpaced.member_outputs().first(),
1763                    expected.first(),
1764                    "{context}"
1765                );
1766                let paced = charged_member_run(
1767                    evaluator,
1768                    source,
1769                    parallel_leaves_query(),
1770                    range,
1771                    direction,
1772                    SkipPolicy::DRAIN_ONLY,
1773                    charged_buckets(5, false),
1774                )
1775                .await;
1776                assert_eq!(paced.member_outputs(), expected, "{context}");
1777            }
1778        }
1779    }
1780
1781    /// An edge-bucket item with a member inside the scan range is an emission
1782    /// even though it also holds members outside the range: it is the scan's
1783    /// first output and restarts the count, so the watermark of its own
1784    /// bucket is held and the next one waits for five more charged rows.
1785    /// Ascending, bucket 0 matches members 1 and 3 and the range starts at 2;
1786    /// descending, bucket 9 matches `9 * BUCKET_SIZE + 1` and `+ 3` and the
1787    /// range ends at `+ 2`.
1788    #[tokio::test]
1789    async fn edge_item_with_in_range_member_restarts_count() {
1790        let cases = [
1791            (
1792                ScanDirection::Ascending,
1793                parallel_leaves_source_with_matches(10, &[(0, &[1, 3][..])]),
1794                2..(10 * BUCKET_SIZE),
1795                3,
1796                [4, 7, 10],
1797            ),
1798            (
1799                ScanDirection::Descending,
1800                parallel_leaves_source_with_matches(10, &[(9, &[1, 3][..])]),
1801                0..(9 * BUCKET_SIZE + 2),
1802                9 * BUCKET_SIZE + 1,
1803                [6, 3, 0],
1804            ),
1805        ];
1806        for evaluator in [Evaluator::Iter, Evaluator::Stream] {
1807            for (direction, source, range, in_range_member, emitted_buckets) in cases.clone() {
1808                let mut expected: Vec<ObservedMemberOutput> =
1809                    vec![Ok(Watermarked::Item(in_range_member))];
1810                expected.extend(emitted_buckets.into_iter().map(watermark_at_bucket));
1811                let paced = charged_member_run(
1812                    evaluator,
1813                    source,
1814                    parallel_leaves_query(),
1815                    range,
1816                    direction,
1817                    SkipPolicy::DRAIN_ONLY,
1818                    charged_buckets(5, false),
1819                )
1820                .await;
1821                assert_eq!(
1822                    paced.member_outputs(),
1823                    expected,
1824                    "{evaluator:?}, {direction:?}"
1825                );
1826            }
1827        }
1828    }
1829
1830    /// `a AND b` where `a` holds member 1 in every tenth bucket of `0..=40`
1831    /// and `b` holds member 2 in every bucket, plus member 1 in bucket 20:
1832    /// each `a` row leaves `b` nine dead rows behind, and the query matches
1833    /// only `20 * BUCKET_SIZE + 1`.
1834    fn sparse_and_dense_source() -> TestBucketSource {
1835        TestBucketSource {
1836            buckets: Arc::new(BTreeMap::from([
1837                (
1838                    test_key(b"a"),
1839                    (0..=40)
1840                        .step_by(10)
1841                        .map(|bucket| (bucket, vec![1]))
1842                        .collect(),
1843                ),
1844                (
1845                    test_key(b"b"),
1846                    (0..=40)
1847                        .map(|bucket| {
1848                            if bucket == 20 {
1849                                (bucket, vec![1, 2])
1850                            } else {
1851                                (bucket, vec![2])
1852                            }
1853                        })
1854                        .collect(),
1855                ),
1856            ])),
1857        }
1858    }
1859
1860    /// With gap catch-up draining and seeking past dead rows, pacing counts
1861    /// all the scan work: each emitted watermark after the scan's first
1862    /// output comes at least `min_buckets` of work (rows charged plus reopen
1863    /// steps) after the previous emission and is a watermark of the unpaced
1864    /// run, in order, while items are exactly the unpaced run's.
1865    #[tokio::test]
1866    async fn charged_buckets_paces_by_scan_work_during_gap_catch_up() {
1867        const MIN_BUCKETS: u64 = 3;
1868        let range = 0..(41 * BUCKET_SIZE);
1869        for evaluator in [Evaluator::Iter, Evaluator::Stream] {
1870            for direction in [ScanDirection::Ascending, ScanDirection::Descending] {
1871                for policy in [
1872                    SkipPolicy::DRAIN_ONLY,
1873                    SkipPolicy {
1874                        drain_probe_rows: std::num::NonZeroU32::new(2),
1875                    },
1876                ] {
1877                    let context = format!("{evaluator:?}, {direction:?}, {policy:?}");
1878                    let unpaced = charged_member_run(
1879                        evaluator,
1880                        sparse_and_dense_source(),
1881                        skewed_and_query(),
1882                        range.clone(),
1883                        direction,
1884                        policy,
1885                        EVERY_WATERMARK,
1886                    )
1887                    .await;
1888                    let paced = charged_member_run(
1889                        evaluator,
1890                        sparse_and_dense_source(),
1891                        skewed_and_query(),
1892                        range.clone(),
1893                        direction,
1894                        policy,
1895                        charged_buckets(MIN_BUCKETS, false),
1896                    )
1897                    .await;
1898
1899                    // The scan crosses dead gaps, seeking past them when the
1900                    // policy probes.
1901                    assert!(paced.metrics.buckets_discarded > 0, "{context}");
1902                    assert_eq!(
1903                        paced.metrics.leaf_seeks > 0,
1904                        policy.drain_probe_rows.is_some(),
1905                        "{context}"
1906                    );
1907
1908                    assert_eq!(unpaced.items(), vec![20 * BUCKET_SIZE + 1], "{context}");
1909                    assert_eq!(paced.items(), unpaced.items(), "{context}");
1910                    assert_eq!(
1911                        paced.outputs.first().map(|(output, _)| output),
1912                        unpaced.outputs.first().map(|(output, _)| output),
1913                        "first output must not be held: {context}"
1914                    );
1915
1916                    let paced_watermarks = paced.watermarks();
1917                    let unpaced_watermarks = unpaced.watermarks();
1918                    let mut unmatched_unpaced = unpaced_watermarks.iter();
1919                    for position in &paced_watermarks {
1920                        assert!(
1921                            unmatched_unpaced.any(|candidate| candidate == position),
1922                            "paced {paced_watermarks:?} is not a subsequence of unpaced \
1923                             {unpaced_watermarks:?}: {context}"
1924                        );
1925                    }
1926                    // Some watermarks are held and some emitted after the
1927                    // first, so the spacing check below is not vacuous.
1928                    assert!(
1929                        paced_watermarks.len() < unpaced_watermarks.len(),
1930                        "no watermark held: {context}"
1931                    );
1932                    assert!(
1933                        paced_watermarks.len() >= 2,
1934                        "no watermark after the first emitted: {context}"
1935                    );
1936
1937                    for pair in paced.outputs.windows(2) {
1938                        let [(_, previous), (output, current)] = pair else {
1939                            unreachable!("windows(2) yields pairs");
1940                        };
1941                        if matches!(output, Ok(Watermarked::Watermark(_))) {
1942                            let work = current.work(policy) - previous.work(policy);
1943                            assert!(
1944                                work >= MIN_BUCKETS,
1945                                "{output:?} emitted {work} of scan work after the previous \
1946                                 emission: {context}"
1947                            );
1948                        }
1949                    }
1950                }
1951            }
1952        }
1953    }
1954
1955    /// Both evaluators emit a watermark exactly when `min_buckets` of scan
1956    /// work, rows charged plus `drain_probe_rows` per reopen step, has been
1957    /// done since the last emission. Over [`sparse_and_dense_source`] with a
1958    /// two-row probe, `b` drains two dead rows and reopens behind every `a`
1959    /// row, one leaf per step, so some watermarks are due only because of
1960    /// the reopens.
1961    #[tokio::test]
1962    async fn both_evaluators_pace_by_rows_and_reopen_steps() {
1963        let range = 0..(41 * BUCKET_SIZE);
1964        let policy = SkipPolicy {
1965            drain_probe_rows: std::num::NonZeroU32::new(2),
1966        };
1967        for evaluator in [Evaluator::Iter, Evaluator::Stream] {
1968            for direction in [ScanDirection::Ascending, ScanDirection::Descending] {
1969                let unpaced = charged_member_run(
1970                    evaluator,
1971                    sparse_and_dense_source(),
1972                    skewed_and_query(),
1973                    range.clone(),
1974                    direction,
1975                    policy,
1976                    EVERY_WATERMARK,
1977                )
1978                .await;
1979                assert!(
1980                    unpaced.metrics.leaf_seeks > 0,
1981                    "{evaluator:?}, {direction:?}"
1982                );
1983                let mut due_only_to_reopens = 0;
1984                for min_buckets in 1..=20 {
1985                    let context =
1986                        format!("{evaluator:?}, {direction:?}, min_buckets {min_buckets}");
1987                    // The pacing rule applied to the unpaced run: items and
1988                    // the first output are always emitted, and every
1989                    // emission restarts the count.
1990                    let mut expected = Vec::new();
1991                    let mut last_emission: Option<ScanCounts> = None;
1992                    for (output, counts) in &unpaced.outputs {
1993                        if matches!(output, Ok(Watermarked::Watermark(_)))
1994                            && let Some(last) = last_emission
1995                        {
1996                            if counts.work(policy) - last.work(policy) < min_buckets {
1997                                continue;
1998                            }
1999                            if counts.rows - last.rows < min_buckets {
2000                                due_only_to_reopens += 1;
2001                            }
2002                        }
2003                        last_emission = Some(*counts);
2004                        expected.push(output.clone());
2005                    }
2006                    let paced = charged_member_run(
2007                        evaluator,
2008                        sparse_and_dense_source(),
2009                        skewed_and_query(),
2010                        range.clone(),
2011                        direction,
2012                        policy,
2013                        charged_buckets(min_buckets, false),
2014                    )
2015                    .await;
2016                    assert_eq!(paced.member_outputs(), expected, "{context}");
2017                }
2018                assert!(
2019                    due_only_to_reopens > 0,
2020                    "no watermark was due only because of reopens: {evaluator:?}, {direction:?}"
2021                );
2022            }
2023        }
2024    }
2025}