Skip to main content

sui_inverted_index/bitmap_query/
mod.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! DNF bitmap index queries over ordered bucket streams.
5//!
6//! Callers build a `BitmapQuery` as an OR of terms. Each term is an AND of
7//! signed dimension-key literals. Evaluation yields matching bitmap members as
8//! they are produced. Back-pressure from downstream consumers, e.g. a
9//! `.take(page_size)`, propagates back to the backend-provided bucket streams
10//! and avoids materializing matches we won't use.
11//!
12//! Queries are intentionally restricted to anchored DNF: every term must contain
13//! at least one positive literal. Positive literals give the evaluator concrete
14//! bitmap streams to scan and intersect; negative literals only shrink those
15//! candidate streams. Negative-only terms such as `NOT sender = A` are
16//! supported by anchoring them upstream on a universe include — a stored
17//! existence marker in event-space (`EventExtant`), a scan-time-synthesized
18//! dense leaf in tx-space (`TxUniverse`, see [`dense_universe_buckets`]) — so
19//! the evaluator itself stays a set of ordered stream merge-joins with no
20//! complement-specific code path. The full-range scan such a term implies is
21//! inherent to negation and is bounded by the per-request bucket budget.
22//!
23//! Backends provide one ordered `(bucket_id, RoaringBitmap)` stream or iterator
24//! per dimension key. The merge-join machinery here is storage-agnostic:
25//! BigTable, RocksDB, or any other backend can reuse it as long as its bucket
26//! source is sparse, ordered by the requested scan direction, and stores bitmap
27//! positions relative to that bucket.
28
29use std::collections::HashMap;
30use std::collections::HashSet;
31use std::ops::Range;
32
33use anyhow::Result;
34use anyhow::bail;
35use futures::stream::BoxStream;
36use roaring::RoaringBitmap;
37
38use crate::dimensions::IndexDimension;
39
40mod iter;
41mod stream;
42
43pub use iter::eval_bitmap_query_bucket_iter;
44pub use stream::BitmapScanMetrics;
45pub use stream::eval_bitmap_query_stream;
46pub use stream::flatten_watermarked_buckets;
47
48// Cross-checked against the iterative evaluator in iter.rs tests.
49#[cfg(test)]
50pub(crate) use stream::BitmapScanBudget;
51#[cfg(test)]
52pub(crate) use stream::eval_bitmap_query_bucket_stream;
53
54/// Terminal raised by a single leaf/backend bucket stream. Positionless by
55/// construction — one leaf cannot know the merged multi-term floor. Leaf stops
56/// never reach a List driver: the DNF evaluators consume them and re-raise a
57/// [`ScanStop`] carrying the merged frontier.
58#[derive(Debug, thiserror::Error)]
59pub enum LeafStop {
60    /// This leaf's share of the bucket-scan budget is exhausted.
61    #[error("bitmap scan limit reached")]
62    BudgetExhausted,
63    /// The request's cancellation token fired.
64    #[error("bitmap scan cancelled")]
65    Cancelled,
66    /// A backend/storage fault.
67    #[error(transparent)]
68    Fault(anyhow::Error),
69}
70
71/// Terminal signal of a merged bitmap eval stream, raised on the error channel
72/// so the `try_stream!` pipeline short-circuits (a clean end-of-stream means
73/// "scanned the whole range"). The List handlers map each variant to a wire
74/// outcome with one exhaustive match.
75///
76/// Mental model: leaves raise positionless [`LeafStop`]s; the evaluator turns
77/// them into a `ScanStop` that ALWAYS carries the resume frontier on
78/// `ScanLimit`; in-band `Watermarked::Watermark`s are only mid-scan progress
79/// beacons, never the resume channel.
80#[derive(Debug, thiserror::Error)]
81pub enum ScanStop {
82    /// Budget exhausted: a graceful early stop. The handler ends the stream
83    /// with `QUERY_END_REASON_SCAN_LIMIT`; the frontier is the resume cursor.
84    #[error("bitmap scan limit reached")]
85    ScanLimit {
86        /// Merged floor position every term provably scanned to when the budget
87        /// died — the exact value the stopping round's progress beacon would
88        /// have carried. It lives in the scanned index's member-id domain
89        /// (tx-seq for transaction/checkpoint indexes; encoded event-seq — see
90        /// `event_seq` — for the event index). Consumers apply their existing
91        /// domain conversion and resume arithmetic.
92        scan_frontier: u64,
93    },
94    /// Cancelled → gRPC `Cancelled` status.
95    #[error("bitmap scan cancelled")]
96    Cancelled,
97    /// Backend/storage fault → gRPC `Internal`, error carried unchanged.
98    #[error(transparent)]
99    Fault(anyhow::Error),
100}
101
102impl From<anyhow::Error> for LeafStop {
103    fn from(err: anyhow::Error) -> Self {
104        LeafStop::Fault(err)
105    }
106}
107
108impl From<anyhow::Error> for ScanStop {
109    fn from(err: anyhow::Error) -> Self {
110        ScanStop::Fault(err)
111    }
112}
113
114/// Reduce the leaf stops raised in one evaluator round to the driver-facing
115/// terminal. Precedence: Fault > BudgetExhausted > Cancelled (a real fault must
116/// surface as Internal; a budget stop with its frontier beats a bare Cancel —
117/// the resume point costs nothing to deliver). `frontier` is the merged floor
118/// the caller computed for this round; it is bound into `ScanLimit` only.
119/// Panics on empty input — evaluators only collapse when a leaf actually erred.
120pub(crate) fn collapse(stops: Vec<LeafStop>, scan_frontier: u64) -> ScanStop {
121    assert!(!stops.is_empty(), "collapse on empty Vec");
122    let mut cancelled = false;
123    let mut budget = false;
124    let mut faults: Vec<anyhow::Error> = Vec::new();
125    for s in stops {
126        match s {
127            LeafStop::Cancelled => cancelled = true,
128            LeafStop::BudgetExhausted => budget = true,
129            LeafStop::Fault(err) => faults.push(err),
130        }
131    }
132    match faults.len() {
133        0 => {
134            if budget {
135                ScanStop::ScanLimit { scan_frontier }
136            } else {
137                debug_assert!(cancelled, "collapse saw only non-erroring leaves");
138                ScanStop::Cancelled
139            }
140        }
141        1 => ScanStop::Fault(faults.pop().expect("len == 1")),
142        n => {
143            let combined = faults
144                .iter()
145                .enumerate()
146                .map(|(i, err)| format!("  [{i}] {err}"))
147                .collect::<Vec<_>>()
148                .join("\n");
149            ScanStop::Fault(anyhow::anyhow!(
150                "{n} concurrent bitmap scan faults:\n{combined}"
151            ))
152        }
153    }
154}
155
156/// Item or progress watermark flowing through a bitmap eval pipeline.
157/// `Watermark(p)` means every Item with position strictly before `p`
158/// in scan direction has been emitted upstream. Downstream stages must
159/// preserve watermark/item ordering — that's what makes the watermark a
160/// safe resume cursor on timeout.
161#[derive(Clone, Copy, Debug, Eq, PartialEq)]
162pub enum Watermarked<T, P = u64> {
163    Item(T),
164    Watermark(P),
165}
166
167impl<T, P> Watermarked<T, P> {
168    pub fn map_item<U>(self, f: impl FnOnce(T) -> U) -> Watermarked<U, P> {
169        match self {
170            Watermarked::Item(t) => Watermarked::Item(f(t)),
171            Watermarked::Watermark(p) => Watermarked::Watermark(p),
172        }
173    }
174
175    pub fn map_watermark<Q>(self, f: impl FnOnce(P) -> Q) -> Watermarked<T, Q> {
176        match self {
177            Watermarked::Item(t) => Watermarked::Item(t),
178            Watermarked::Watermark(p) => Watermarked::Watermark(f(p)),
179        }
180    }
181}
182
183/// A stream of `(bucket_id, RoaringBitmap)` in the requested bucket order.
184/// Bitmap positions are **relative** to the bucket (u32 offsets `[0, BUCKET_SIZE)`)
185/// - edge trimming against the requested range happens at the flatten step.
186pub type BucketItem = Result<(u64, RoaringBitmap), LeafStop>;
187pub type BucketStream = BoxStream<'static, BucketItem>;
188
189/// A bucket stream that interleaves data buckets with progress watermarks.
190/// The flat DNF driver derives each watermark from the slowest leaf's
191/// position, so the output always reflects "every source has scanned past P."
192pub(crate) type WatermarkedBucket = Result<Watermarked<(u64, RoaringBitmap)>, ScanStop>;
193pub type WatermarkedBucketStream = BoxStream<'static, WatermarkedBucket>;
194
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
196pub enum ScanDirection {
197    Ascending,
198    Descending,
199}
200/// Physical gap-crossing policy. Logical frontier jumps are unconditional;
201/// this only bounds how many charged dead rows a lagging leaf drains from its
202/// open scan per lag episode before seeking past the gap.
203#[derive(Clone, Copy, Debug)]
204pub struct SkipPolicy {
205    /// `None` never seeks and drains dead rows without bound.
206    pub drain_probe_rows: Option<std::num::NonZeroU32>,
207}
208
209impl SkipPolicy {
210    /// Drain dead rows without seeking; logical frontier jumps remain enabled.
211    pub const DRAIN_ONLY: Self = Self {
212        drain_probe_rows: None,
213    };
214}
215
216impl ScanDirection {
217    pub fn is_ascending(self) -> bool {
218        matches!(self, Self::Ascending)
219    }
220}
221
222/// Synthesized bucket sequence for the dense tx-seq universe: one full bitmap
223/// per bucket touched by `range`, in scan-direction order. Backends return this
224/// for the query-only `IndexDimension::TxUniverse` key instead of reading
225/// storage — the tx-seq namespace is dense, so the universe is computable.
226///
227/// Bitmaps carry all `[0, bucket_size)` relative bits even in edge buckets;
228/// trimming against the requested range happens at the flatten step, same as
229/// for stored buckets.
230pub fn dense_universe_buckets(
231    range: Range<u64>,
232    bucket_size: u64,
233    direction: ScanDirection,
234) -> DenseUniverseBuckets {
235    let bits = u32::try_from(bucket_size).expect("bucket size fits in u32");
236    let buckets = if range.is_empty() {
237        0..0
238    } else {
239        (range.start / bucket_size)..(range.end - 1) / bucket_size + 1
240    };
241    DenseUniverseBuckets {
242        buckets,
243        direction,
244        bits,
245    }
246}
247
248/// Synthesized dense-universe bucket iterator.
249pub struct DenseUniverseBuckets {
250    buckets: Range<u64>,
251    direction: ScanDirection,
252    bits: u32,
253}
254
255impl DenseUniverseBuckets {
256    /// Reposition to the first bucket at or past `bucket` in scan direction.
257    pub fn seek_bucket(&mut self, bucket: u64) {
258        match self.direction {
259            ScanDirection::Ascending => self.buckets.start = self.buckets.start.max(bucket),
260            ScanDirection::Descending => {
261                self.buckets.end = self.buckets.end.min(bucket.saturating_add(1))
262            }
263        }
264    }
265}
266
267impl Iterator for DenseUniverseBuckets {
268    type Item = (u64, RoaringBitmap);
269
270    fn next(&mut self) -> Option<Self::Item> {
271        let bucket = match self.direction {
272            ScanDirection::Ascending => self.buckets.next(),
273            ScanDirection::Descending => self.buckets.next_back(),
274        }?;
275        let mut bitmap = RoaringBitmap::new();
276        bitmap.insert_range(0..self.bits);
277        Some((bucket, bitmap))
278    }
279}
280
281/// Storage backend that can scan one bitmap dimension key over a member range.
282///
283/// The returned stream must be sparse and ordered by the requested direction.
284/// Missing bucket rows are interpreted as all-zero bitmaps by the merge-join
285/// operators.
286pub trait BitmapBucketSource: Clone + Send + 'static {
287    fn scan_bucket_stream(
288        &self,
289        dimension_key: Vec<u8>,
290        range: Range<u64>,
291        direction: ScanDirection,
292    ) -> BucketStream;
293}
294
295/// Synchronous bucket iterator that can reposition within its scan bounds.
296pub trait SeekableBucketIterator: Iterator<Item = BucketItem> {
297    /// Reposition so the next bucket is the first at or past `bucket` in scan
298    /// direction. Implementations preserve budget and first-row reservation
299    /// state across seeks.
300    fn seek_bucket(&mut self, bucket: u64);
301}
302
303/// Storage backend that can scan one bitmap dimension key synchronously.
304///
305/// This is for request-local backends such as RocksDB, where the bucket scan
306/// naturally owns or borrows a synchronous iterator. The iterator evaluator is
307/// fully synchronous so these iterators can stay on the blocking task that owns
308/// them.
309pub trait BitmapBucketIteratorSource<'a>: Clone + 'a {
310    type Iter: SeekableBucketIterator + 'a;
311
312    fn scan_bucket_iter(
313        &self,
314        dimension_key: Vec<u8>,
315        range: Range<u64>,
316        direction: ScanDirection,
317    ) -> Self::Iter;
318}
319
320/// A DNF query over bitmap dimension scans.
321///
322/// A query is a disjunction of terms. It must contain at least one term, and
323/// every term must be anchored by at least one included dimension key.
324#[derive(Clone, Debug)]
325pub struct BitmapQuery {
326    terms: Vec<BitmapTerm>,
327}
328
329/// One conjunction in a DNF bitmap query.
330///
331/// A term is a conjunction of signed literals. It must include at least one
332/// positive literal so the evaluator has a finite candidate stream to refine.
333#[derive(Clone, Debug)]
334pub struct BitmapTerm {
335    literals: Vec<BitmapLiteral>,
336}
337
338/// Validated `[dimension_tag][dimension_value]` lookup key.
339#[derive(Clone, Debug, Eq, PartialEq, Hash)]
340pub struct BitmapKey(Vec<u8>);
341
342/// One signed dimension-key literal in a bitmap term.
343#[derive(Clone, Debug)]
344pub enum BitmapLiteral {
345    Include(BitmapKey),
346    Exclude(BitmapKey),
347}
348
349impl BitmapKey {
350    pub fn new(bytes: Vec<u8>) -> Result<Self> {
351        if bytes.is_empty() {
352            bail!("bitmap dimension key must not be empty");
353        }
354        if bytes.len() == 1 {
355            bail!("bitmap dimension value must not be empty");
356        }
357        if IndexDimension::from_tag_byte(bytes[0]).is_none() {
358            bail!("unknown bitmap dimension tag {}", bytes[0]);
359        }
360        Ok(Self(bytes))
361    }
362
363    pub fn into_inner(self) -> Vec<u8> {
364        self.0
365    }
366
367    pub fn as_bytes(&self) -> &[u8] {
368        &self.0
369    }
370}
371
372impl TryFrom<Vec<u8>> for BitmapKey {
373    type Error = anyhow::Error;
374
375    fn try_from(value: Vec<u8>) -> Result<Self> {
376        Self::new(value)
377    }
378}
379
380impl BitmapLiteral {
381    pub fn include(dimension_key: Vec<u8>) -> Result<Self> {
382        Ok(Self::Include(BitmapKey::new(dimension_key)?))
383    }
384
385    pub fn exclude(dimension_key: Vec<u8>) -> Result<Self> {
386        Ok(Self::Exclude(BitmapKey::new(dimension_key)?))
387    }
388
389    pub fn key_bytes(&self) -> &[u8] {
390        match self {
391            BitmapLiteral::Include(k) | BitmapLiteral::Exclude(k) => k.as_bytes(),
392        }
393    }
394}
395
396impl BitmapQuery {
397    pub fn new(terms: Vec<BitmapTerm>) -> Result<Self> {
398        if terms.is_empty() {
399            bail!("bitmap query must contain at least one term");
400        }
401        Ok(Self { terms })
402    }
403
404    pub fn scan(dimension_key: Vec<u8>) -> Result<Self> {
405        Ok(Self {
406            terms: vec![BitmapTerm::new(vec![BitmapLiteral::include(
407                dimension_key,
408            )?])?],
409        })
410    }
411
412    /// Count of distinct dimension-key leaves the query will scan. Identical
413    /// keys across literals (whether within a term or across terms) collapse
414    /// to one leaf at evaluation time, so the per-request budget floor —
415    /// "every leaf can emit its first watermark" — applies to this
416    /// deduplicated count, not the raw literal total.
417    pub fn unique_leaf_count(&self) -> usize {
418        self.terms
419            .iter()
420            .flat_map(|t| t.literals.iter().map(|l| l.key_bytes()))
421            .collect::<HashSet<_>>()
422            .len()
423    }
424
425    pub fn terms(&self) -> &[BitmapTerm] {
426        &self.terms
427    }
428}
429
430impl BitmapTerm {
431    pub fn new(literals: Vec<BitmapLiteral>) -> Result<Self> {
432        if !literals
433            .iter()
434            .any(|literal| matches!(literal, BitmapLiteral::Include(_)))
435        {
436            bail!("bitmap query term must contain at least one include literal");
437        }
438        Ok(Self { literals })
439    }
440
441    pub fn literals(&self) -> &[BitmapLiteral] {
442        &self.literals
443    }
444}
445
446/// Deduplicated leaf list + per-term references over those leaves, ready for
447/// the evaluator. `keys[i]` is the dimension-key bytes for leaf `i`; each
448/// [`TermSpec`] references leaves by that index.
449pub(crate) struct DedupedQuery {
450    pub(crate) keys: Vec<Vec<u8>>,
451    pub(crate) terms: Vec<TermSpec>,
452}
453
454/// Deduplicate literals across the whole query and translate each term's
455/// includes/excludes into indices over the shared leaf list.
456///
457/// Dedup is keyed on encoded dimension-key bytes, so the same key appearing
458/// in multiple terms (e.g. `(sender=A AND module=X) OR (sender=A AND
459/// type=Y)`) maps to a single backend scan instead of two duplicate scans.
460pub(crate) fn build_term_specs(terms: Vec<BitmapTerm>) -> DedupedQuery {
461    let mut key_to_idx: HashMap<Vec<u8>, usize> = HashMap::new();
462    let mut keys: Vec<Vec<u8>> = Vec::new();
463    let mut specs: Vec<TermSpec> = Vec::with_capacity(terms.len());
464    for term in terms {
465        let mut include_idx = Vec::with_capacity(term.literals.len());
466        let mut exclude_idx = Vec::with_capacity(term.literals.len());
467        for literal in term.literals {
468            let (push_target, key) = match literal {
469                BitmapLiteral::Include(k) => (&mut include_idx, k.into_inner()),
470                BitmapLiteral::Exclude(k) => (&mut exclude_idx, k.into_inner()),
471            };
472            let idx = match key_to_idx.get(&key) {
473                Some(&i) => i,
474                None => {
475                    let i = keys.len();
476                    key_to_idx.insert(key.clone(), i);
477                    keys.push(key);
478                    i
479                }
480            };
481            push_target.push(idx);
482        }
483        specs.push(TermSpec {
484            includes: include_idx,
485            excludes: exclude_idx,
486            unsatisfiable: false,
487        });
488    }
489    DedupedQuery { keys, terms: specs }
490}
491
492/// The less-advanced of two frontier positions in scan direction: the min
493/// ascending, the max descending. Used to keep a merged frontier bounded by
494/// the slowest contributor.
495fn bound_in_direction(a: u64, b: u64, direction: ScanDirection) -> u64 {
496    match direction {
497        ScanDirection::Ascending => a.min(b),
498        ScanDirection::Descending => a.max(b),
499    }
500}
501/// The more-advanced of two positions in scan direction: max ascending, min
502/// descending.
503pub(crate) fn advance_in_direction(a: u64, b: u64, direction: ScanDirection) -> u64 {
504    match direction {
505        ScanDirection::Ascending => a.max(b),
506        ScanDirection::Descending => a.min(b),
507    }
508}
509
510/// Whether `a` is strictly before `b` in scan direction.
511pub(crate) fn strictly_before(a: u64, b: u64, direction: ScanDirection) -> bool {
512    match direction {
513        ScanDirection::Ascending => a < b,
514        ScanDirection::Descending => a > b,
515    }
516}
517
518/// Whether emitting `next` as a watermark advances the frontier past the
519/// previously emitted one. Ascending frontiers strictly increase,
520/// descending strictly decrease; the first watermark always advances.
521fn frontier_advanced(prev: Option<u64>, next: u64, direction: ScanDirection) -> bool {
522    match prev {
523        None => true,
524        Some(prev) => match direction {
525            ScanDirection::Ascending => next > prev,
526            ScanDirection::Descending => next < prev,
527        },
528    }
529}
530
531/// Clamped member-id edges of `bucket` in scan direction: `(pre, post)` where
532/// `pre` is the leading edge (everything before it is already covered) and
533/// `post` is the trailing edge (everything up to and including the bucket is
534/// covered). Ascending: `(low, high)`; descending: `(high, low)`. Both clamped
535/// to the request range so cursors stay in-bounds when they round-trip into a
536/// follow-up request with a different range.
537pub(crate) fn bucket_edges(
538    bucket: u64,
539    bucket_size: u64,
540    range: &Range<u64>,
541    direction: ScanDirection,
542) -> (u64, u64) {
543    let start = bucket.saturating_mul(bucket_size);
544    let end = start.saturating_add(bucket_size);
545    match direction {
546        ScanDirection::Ascending => (start.max(range.start), end.min(range.end)),
547        ScanDirection::Descending => (end.min(range.end), start.max(range.start)),
548    }
549}
550
551/// Pull the snapshotted bitmap for leaf `i` to give to one referencing term
552/// this round. Returns `None` if the leaf isn't on the floor bucket (so the
553/// term short-circuits). When `remaining_refs[i] > 1`, the bitmap is cloned so
554/// other referencing terms still see it; the last reference takes by value to
555/// avoid an unnecessary copy in the common single-reference case.
556pub(crate) fn take_snapshot_bitmap(
557    snapshot: &mut [Option<RoaringBitmap>],
558    remaining_refs: &mut [usize],
559    on_floor: &[bool],
560    i: usize,
561) -> Option<RoaringBitmap> {
562    if !on_floor[i] {
563        return None;
564    }
565    if remaining_refs[i] > 1 {
566        remaining_refs[i] -= 1;
567        snapshot[i].clone()
568    } else {
569        remaining_refs[i] = remaining_refs[i].saturating_sub(1);
570        snapshot[i].take()
571    }
572}
573
574/// Per-round refcount: how many satisfiable term-side slots reference each
575/// on-floor leaf. Drives [`take_snapshot_bitmap`]'s take-vs-clone decision so
576/// the last referencing slot reclaims the bitmap by value.
577pub(crate) fn count_on_floor_refs(terms: &[TermSpec], on_floor: &[bool]) -> Vec<usize> {
578    let mut refs = vec![0usize; on_floor.len()];
579    for term in terms {
580        if term.unsatisfiable {
581            continue;
582        }
583        for &i in term.includes.iter().chain(term.excludes.iter()) {
584            if on_floor[i] {
585                refs[i] += 1;
586            }
587        }
588    }
589    refs
590}
591
592/// Recompute leaf liveness from current term state. A leaf becomes
593/// `unreferenced` when no satisfiable term still points at it, or when its
594/// head is at EOF (the bucket stream is permanently exhausted; any further
595/// peek would be wasted work, and any include-referencing term will be marked
596/// `unsatisfiable` separately).
597///
598/// `unreferenced` is monotonic — entries only transition false → true — so
599/// this is safe to invoke each round; previously-retired leaves stay retired.
600pub(crate) fn recompute_unreferenced(
601    terms: &[TermSpec],
602    class: &[Option<LeafHead>],
603    unreferenced: &mut [bool],
604) {
605    let leaf_count = unreferenced.len();
606    let mut referenced = vec![false; leaf_count];
607    for term in terms {
608        if term.unsatisfiable {
609            continue;
610        }
611        for &i in term.includes.iter().chain(term.excludes.iter()) {
612            if !unreferenced[i] && !matches!(class[i], Some(LeafHead::Eof)) {
613                referenced[i] = true;
614            }
615        }
616    }
617    for i in 0..leaf_count {
618        if !referenced[i] {
619            unreferenced[i] = true;
620        }
621    }
622}
623
624/// Evaluate one DNF term at a single bucket from the per-leaf bitmaps present
625/// there: intersect the includes (any absent include ⇒ empty term), then
626/// subtract the union of the present excludes (`a AND NOT b`). Returns the
627/// term's matches at the bucket, or `None` if empty. Bitmaps are taken by value
628/// so the caller hands over the consumed leaf rows without cloning.
629pub(crate) fn eval_term_at_bucket(
630    includes: Vec<Option<RoaringBitmap>>,
631    excludes: Vec<Option<RoaringBitmap>>,
632) -> Option<RoaringBitmap> {
633    let mut acc: Option<RoaringBitmap> = None;
634    for include in includes {
635        // A missing include means the intersection is empty at this bucket.
636        let bitmap = include?;
637        acc = Some(match acc {
638            None => bitmap,
639            Some(a) => a & bitmap,
640        });
641    }
642    // Anchored terms always carry at least one include, so `acc` is `Some`.
643    let mut acc = acc?;
644    for exclude in excludes.into_iter().flatten() {
645        acc -= exclude;
646    }
647    (!acc.is_empty()).then_some(acc)
648}
649
650/// One DNF term, as index spans into the flat leaf vector. Shared by the stream
651/// and iterator drivers.
652pub(crate) struct TermSpec {
653    pub(crate) includes: Vec<usize>,
654    pub(crate) excludes: Vec<usize>,
655    /// Set once any include leaf hits EOF: the term's intersection is
656    /// permanently empty (it can never match again). Latched.
657    pub(crate) unsatisfiable: bool,
658}
659
660/// A leaf's head this round, from a non-consuming peek.
661pub(crate) enum LeafHead {
662    Bucket(u64),
663    Eof,
664    Error,
665}
666/// Compute the furthest bucket each active leaf can skip to without changing
667/// query results.
668pub(crate) fn leaf_skip_targets(
669    terms: &[TermSpec],
670    class: &[Option<LeafHead>],
671    unreferenced: &[bool],
672    direction: ScanDirection,
673) -> Vec<Option<u64>> {
674    // Go through each conjunction in the top-level query disjunction.
675    let term_not_before: Vec<Option<u64>> = terms
676        .iter()
677        .map(|term| {
678            if term.unsatisfiable {
679                return None;
680            }
681            // For each conjunction, find its furthest-ahead include leaf.
682            // As far as this conjunction is concerned, every leaf it references
683            // can skip to that bucket. A shared leaf may still be constrained by
684            // another conjunction. Excludes don't set this bound. No exclude row
685            // just means nothing to subtract.
686            term.includes
687                .iter()
688                .filter_map(|&i| match class.get(i).and_then(Option::as_ref) {
689                    Some(LeafHead::Bucket(bucket)) => Some(*bucket),
690                    Some(LeafHead::Error | LeafHead::Eof) | None => None,
691                })
692                .reduce(|a, b| advance_in_direction(a, b, direction))
693        })
694        .collect();
695
696    // Now with term-level knowledge, iterate through the deduped leaves. Find the nearest
697    // safe bound that we can skip ahead to among all terms that reference a leaf.
698    class
699        .iter()
700        .enumerate()
701        .map(|(i, head)| {
702            if unreferenced.get(i).copied().unwrap_or(true) {
703                return None;
704            }
705            let Some(LeafHead::Bucket(head)) = head else {
706                return None;
707            };
708
709            let mut target = None;
710            for (term_index, term) in terms.iter().enumerate() {
711                if term.unsatisfiable
712                    || !term
713                        .includes
714                        .iter()
715                        .chain(&term.excludes)
716                        .any(|&leaf| leaf == i)
717                {
718                    continue;
719                }
720
721                let not_before = term_not_before[term_index]?;
722                target = Some(match target {
723                    None => not_before,
724                    Some(current) => bound_in_direction(current, not_before, direction),
725                });
726            }
727            target.filter(|&target| strictly_before(*head, target, direction))
728        })
729        .collect()
730}
731
732#[cfg(test)]
733pub(crate) mod test_utils {
734    use std::collections::BTreeMap;
735    use std::collections::HashMap;
736    use std::sync::Arc;
737    use std::sync::Mutex;
738
739    use futures::StreamExt;
740    use futures::stream;
741
742    use super::*;
743
744    pub(crate) const BUCKET_SIZE: u64 = 100_000;
745    pub(crate) type TestBuckets = BTreeMap<Vec<u8>, Vec<(u64, Vec<u32>)>>;
746    type SeekRecorder = (Vec<u8>, Arc<Mutex<HashMap<Vec<u8>, usize>>>);
747    pub(crate) struct VecBucketIter {
748        items: std::vec::IntoIter<BucketItem>,
749        direction: ScanDirection,
750        recorder: Option<SeekRecorder>,
751    }
752
753    impl Iterator for VecBucketIter {
754        type Item = BucketItem;
755
756        fn next(&mut self) -> Option<Self::Item> {
757            self.items.next()
758        }
759    }
760
761    impl SeekableBucketIterator for VecBucketIter {
762        fn seek_bucket(&mut self, target: u64) {
763            if let Some((key, seek_counts)) = &self.recorder {
764                *seek_counts.lock().unwrap().entry(key.clone()).or_insert(0) += 1;
765            }
766            while matches!(
767                self.items.as_slice().first(),
768                Some(Ok((bucket, _))) if strictly_before(*bucket, target, self.direction)
769            ) {
770                self.items.next();
771            }
772        }
773    }
774
775    #[derive(Clone)]
776    pub(crate) struct TestBucketSource {
777        pub(crate) buckets: Arc<TestBuckets>,
778    }
779
780    impl BitmapBucketSource for TestBucketSource {
781        fn scan_bucket_stream(
782            &self,
783            dimension_key: Vec<u8>,
784            range: Range<u64>,
785            direction: ScanDirection,
786        ) -> BucketStream {
787            stream::iter(self.bucket_items(&dimension_key, range, direction)).boxed()
788        }
789    }
790
791    impl<'a> BitmapBucketIteratorSource<'a> for TestBucketSource {
792        type Iter = VecBucketIter;
793
794        fn scan_bucket_iter(
795            &self,
796            dimension_key: Vec<u8>,
797            range: Range<u64>,
798            direction: ScanDirection,
799        ) -> Self::Iter {
800            VecBucketIter {
801                items: self
802                    .bucket_items(&dimension_key, range, direction)
803                    .into_iter(),
804                direction,
805                recorder: None,
806            }
807        }
808    }
809
810    impl TestBucketSource {
811        pub(crate) fn bucket_items(
812            &self,
813            dimension_key: &[u8],
814            range: Range<u64>,
815            direction: ScanDirection,
816        ) -> Vec<BucketItem> {
817            // Mirror the real backends: the tx-universe key is synthesized at
818            // scan time, never read from stored buckets.
819            if dimension_key == universe_key() {
820                return dense_universe_buckets(range, BUCKET_SIZE, direction)
821                    .map(Ok)
822                    .collect();
823            }
824            let mut buckets = self.buckets.get(dimension_key).cloned().unwrap_or_default();
825            if range.is_empty() {
826                buckets.clear();
827            } else {
828                let first_bucket = range.start / BUCKET_SIZE;
829                let last_bucket = (range.end - 1) / BUCKET_SIZE;
830                buckets.retain(|(bucket, _)| first_bucket <= *bucket && *bucket <= last_bucket);
831            }
832            if matches!(direction, ScanDirection::Descending) {
833                buckets.reverse();
834            }
835            buckets
836                .into_iter()
837                .map(|(bucket_id, bits)| Ok((bucket_id, make_bitmap(&bits))))
838                .collect()
839        }
840    }
841
842    pub(crate) fn make_bitmap(bits: &[u32]) -> RoaringBitmap {
843        let mut bm = RoaringBitmap::new();
844        for &b in bits {
845            bm.insert(b);
846        }
847        bm
848    }
849
850    pub(crate) fn test_key(value: &[u8]) -> Vec<u8> {
851        crate::dimensions::encode_dimension_key(crate::dimensions::IndexDimension::Sender, value)
852    }
853
854    pub(crate) fn universe_key() -> Vec<u8> {
855        crate::dimensions::encode_dimension_key(
856            crate::dimensions::IndexDimension::TxUniverse,
857            crate::dimensions::TX_UNIVERSE_VALUE,
858        )
859    }
860
861    pub(crate) fn include(value: &[u8]) -> BitmapLiteral {
862        BitmapLiteral::include(test_key(value)).unwrap()
863    }
864
865    pub(crate) fn include_universe() -> BitmapLiteral {
866        BitmapLiteral::include(universe_key()).unwrap()
867    }
868
869    /// Full `[0, BUCKET_SIZE)` bitmap — what the synthesized universe leaf
870    /// yields per bucket.
871    pub(crate) fn full_bucket() -> RoaringBitmap {
872        let mut bm = RoaringBitmap::new();
873        bm.insert_range(0..BUCKET_SIZE as u32);
874        bm
875    }
876
877    /// A `TestBucketSource` that records how many times `scan_bucket_*` is
878    /// invoked per dimension key. Used to verify the evaluator deduplicates
879    /// leaves across terms (a key referenced from multiple terms should be
880    /// scanned exactly once).
881    #[derive(Clone)]
882    pub(crate) struct CountingBucketSource {
883        pub(crate) buckets: Arc<TestBuckets>,
884        scan_counts: Arc<Mutex<HashMap<Vec<u8>, usize>>>,
885        seek_counts: Arc<Mutex<HashMap<Vec<u8>, usize>>>,
886    }
887
888    impl CountingBucketSource {
889        pub(crate) fn new(buckets: TestBuckets) -> Self {
890            Self {
891                buckets: Arc::new(buckets),
892                scan_counts: Arc::new(Mutex::new(HashMap::new())),
893                seek_counts: Arc::new(Mutex::new(HashMap::new())),
894            }
895        }
896
897        pub(crate) fn scan_count(&self, key: &[u8]) -> usize {
898            self.scan_counts
899                .lock()
900                .unwrap()
901                .get(key)
902                .copied()
903                .unwrap_or(0)
904        }
905        pub(crate) fn seek_count(&self, key: &[u8]) -> usize {
906            self.seek_counts
907                .lock()
908                .unwrap()
909                .get(key)
910                .copied()
911                .unwrap_or(0)
912        }
913
914        fn record(&self, key: &[u8]) {
915            *self
916                .scan_counts
917                .lock()
918                .unwrap()
919                .entry(key.to_vec())
920                .or_insert(0) += 1;
921        }
922
923        fn bucket_items(
924            &self,
925            dimension_key: &[u8],
926            range: Range<u64>,
927            direction: ScanDirection,
928        ) -> Vec<BucketItem> {
929            if dimension_key == universe_key() {
930                return dense_universe_buckets(range, BUCKET_SIZE, direction)
931                    .map(Ok)
932                    .collect();
933            }
934            let mut buckets = self.buckets.get(dimension_key).cloned().unwrap_or_default();
935            if range.is_empty() {
936                buckets.clear();
937            } else {
938                let first_bucket = range.start / BUCKET_SIZE;
939                let last_bucket = (range.end - 1) / BUCKET_SIZE;
940                buckets.retain(|(bucket, _)| first_bucket <= *bucket && *bucket <= last_bucket);
941            }
942            if matches!(direction, ScanDirection::Descending) {
943                buckets.reverse();
944            }
945            buckets
946                .into_iter()
947                .map(|(bucket_id, bits)| Ok((bucket_id, make_bitmap(&bits))))
948                .collect()
949        }
950    }
951
952    impl BitmapBucketSource for CountingBucketSource {
953        fn scan_bucket_stream(
954            &self,
955            dimension_key: Vec<u8>,
956            range: Range<u64>,
957            direction: ScanDirection,
958        ) -> BucketStream {
959            self.record(&dimension_key);
960            stream::iter(self.bucket_items(&dimension_key, range, direction)).boxed()
961        }
962    }
963
964    impl<'a> BitmapBucketIteratorSource<'a> for CountingBucketSource {
965        type Iter = VecBucketIter;
966
967        fn scan_bucket_iter(
968            &self,
969            dimension_key: Vec<u8>,
970            range: Range<u64>,
971            direction: ScanDirection,
972        ) -> Self::Iter {
973            self.record(&dimension_key);
974            VecBucketIter {
975                items: self
976                    .bucket_items(&dimension_key, range, direction)
977                    .into_iter(),
978                direction,
979                recorder: Some((dimension_key, self.seek_counts.clone())),
980            }
981        }
982    }
983
984    pub(crate) fn exclude(value: &[u8]) -> BitmapLiteral {
985        BitmapLiteral::exclude(test_key(value)).unwrap()
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use super::test_utils::exclude;
992    use super::test_utils::include;
993    use super::*;
994
995    #[test]
996    fn bitmap_query_validation_rejects_empty_shapes() {
997        assert!(BitmapQuery::new(Vec::new()).is_err());
998        assert!(BitmapLiteral::include(Vec::new()).is_err());
999        assert!(
1000            BitmapLiteral::include(vec![crate::dimensions::IndexDimension::Sender.tag_byte()])
1001                .is_err()
1002        );
1003        assert!(BitmapLiteral::include(vec![0xff, 0x00]).is_err());
1004        assert!(BitmapTerm::new(vec![exclude(b"neg")]).is_err());
1005    }
1006
1007    #[test]
1008    fn dense_universe_buckets_covers_range_in_direction_order() {
1009        use super::test_utils::BUCKET_SIZE;
1010
1011        // Partial edge buckets still yield full bitmaps — trimming happens at
1012        // the flatten step.
1013        let asc: Vec<u64> =
1014            dense_universe_buckets(150_000..350_001, BUCKET_SIZE, ScanDirection::Ascending)
1015                .map(|(bucket, bitmap)| {
1016                    assert_eq!(bitmap.len(), BUCKET_SIZE);
1017                    bucket
1018                })
1019                .collect();
1020        assert_eq!(asc, vec![1, 2, 3]);
1021
1022        let desc: Vec<u64> =
1023            dense_universe_buckets(150_000..350_001, BUCKET_SIZE, ScanDirection::Descending)
1024                .map(|(bucket, _)| bucket)
1025                .collect();
1026        assert_eq!(desc, vec![3, 2, 1]);
1027
1028        let single: Vec<u64> = dense_universe_buckets(5..6, BUCKET_SIZE, ScanDirection::Ascending)
1029            .map(|(bucket, _)| bucket)
1030            .collect();
1031        assert_eq!(single, vec![0]);
1032
1033        assert_eq!(
1034            dense_universe_buckets(7..7, BUCKET_SIZE, ScanDirection::Ascending).count(),
1035            0
1036        );
1037    }
1038
1039    #[test]
1040    fn unique_leaf_count_counts_distinct_keys_across_terms() {
1041        // Two terms both include `a` and `b`; only `c` is unique to term 1
1042        // and `d` to term 2. The raw literal count is 6, but only 4 unique
1043        // dimension keys are scanned at eval time.
1044        let query = BitmapQuery::new(vec![
1045            BitmapTerm::new(vec![include(b"a"), include(b"b"), include(b"c")]).unwrap(),
1046            BitmapTerm::new(vec![include(b"a"), include(b"b"), include(b"d")]).unwrap(),
1047        ])
1048        .unwrap();
1049        assert_eq!(query.unique_leaf_count(), 4);
1050    }
1051
1052    #[test]
1053    fn build_term_specs_collapses_duplicate_keys_to_one_leaf() {
1054        // Same key used as include in one term and exclude in another should
1055        // share a single leaf slot, with each term referring to it by the
1056        // same index.
1057        let terms = vec![
1058            BitmapTerm::new(vec![include(b"a"), include(b"b")]).unwrap(),
1059            BitmapTerm::new(vec![include(b"b"), exclude(b"a")]).unwrap(),
1060        ];
1061        let DedupedQuery { keys, terms: specs } = build_term_specs(terms);
1062        assert_eq!(keys.len(), 2, "only `a` and `b` are unique");
1063        // Term 0: include a (slot 0), include b (slot 1).
1064        assert_eq!(specs[0].includes, vec![0, 1]);
1065        assert!(specs[0].excludes.is_empty());
1066        // Term 1: include b (slot 1), exclude a (slot 0) — same slots as term 0.
1067        assert_eq!(specs[1].includes, vec![1]);
1068        assert_eq!(specs[1].excludes, vec![0]);
1069    }
1070    #[test]
1071    fn leaf_skip_targets_advance_lagging_include_in_both_directions() {
1072        let term = TermSpec {
1073            includes: vec![0, 1],
1074            excludes: vec![],
1075            unsatisfiable: false,
1076        };
1077        for (direction, heads, expected) in [
1078            (ScanDirection::Ascending, [2, 10], vec![Some(10), None]),
1079            (ScanDirection::Descending, [10, 2], vec![Some(2), None]),
1080        ] {
1081            let class = heads.map(|bucket| Some(LeafHead::Bucket(bucket)));
1082            assert_eq!(
1083                leaf_skip_targets(std::slice::from_ref(&term), &class, &[false; 2], direction),
1084                expected
1085            );
1086        }
1087    }
1088
1089    #[test]
1090    fn leaf_skip_targets_use_least_candidate_across_shared_terms() {
1091        let terms = [
1092            TermSpec {
1093                includes: vec![0],
1094                excludes: vec![1],
1095                unsatisfiable: false,
1096            },
1097            TermSpec {
1098                includes: vec![1],
1099                excludes: vec![],
1100                unsatisfiable: false,
1101            },
1102        ];
1103        for (direction, heads) in [
1104            (ScanDirection::Ascending, [50, 10]),
1105            (ScanDirection::Descending, [10, 50]),
1106        ] {
1107            let class = heads.map(|bucket| Some(LeafHead::Bucket(bucket)));
1108            assert_eq!(
1109                leaf_skip_targets(&terms, &class, &[false; 2], direction)[1],
1110                None
1111            );
1112        }
1113    }
1114
1115    #[test]
1116    fn leaf_skip_targets_drag_exclude_to_include_candidate() {
1117        let term = TermSpec {
1118            includes: vec![0],
1119            excludes: vec![1],
1120            unsatisfiable: false,
1121        };
1122        for (direction, heads, expected) in [
1123            (ScanDirection::Ascending, [50, 10], Some(50)),
1124            (ScanDirection::Descending, [10, 50], Some(10)),
1125        ] {
1126            let class = heads.map(|bucket| Some(LeafHead::Bucket(bucket)));
1127            assert_eq!(
1128                leaf_skip_targets(std::slice::from_ref(&term), &class, &[false; 2], direction)[1],
1129                expected
1130            );
1131        }
1132    }
1133
1134    #[test]
1135    fn leaf_skip_targets_drop_error_heads_and_poison_unknown_candidates() {
1136        for (direction, a_head, c_head) in [
1137            (ScanDirection::Ascending, 0, 9),
1138            (ScanDirection::Descending, 9, 0),
1139        ] {
1140            let terms = [
1141                TermSpec {
1142                    includes: vec![0, 1],
1143                    excludes: vec![],
1144                    unsatisfiable: false,
1145                },
1146                TermSpec {
1147                    includes: vec![0, 2],
1148                    excludes: vec![],
1149                    unsatisfiable: false,
1150                },
1151            ];
1152            let class = [
1153                Some(LeafHead::Bucket(a_head)),
1154                Some(LeafHead::Error),
1155                Some(LeafHead::Bucket(c_head)),
1156            ];
1157            assert_eq!(
1158                leaf_skip_targets(&terms, &class, &[false; 3], direction)[0],
1159                None
1160            );
1161
1162            let poisoned_term = TermSpec {
1163                includes: vec![1],
1164                excludes: vec![0],
1165                unsatisfiable: false,
1166            };
1167            assert_eq!(
1168                leaf_skip_targets(&[poisoned_term], &class, &[false; 3], direction)[0],
1169                None
1170            );
1171        }
1172    }
1173}