Skip to main content

lance_select/
result.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Interval-shaped wrappers around a row-address mask returned by a
5//! scalar-index expression evaluation.
6//!
7//! Each result describes a closed interval `[lower, upper]` in the
8//! lattice of subsets:
9//!
10//! * `lower` — rows the index *guarantees* are in the answer.
11//! * `upper` — rows that *might* be in the answer; rows outside `upper`
12//!   are guaranteed not in the answer.
13//!
14//! The three pre-existing "shapes" map onto degenerate intervals:
15//!
16//! | Old variant | Interval form                          |
17//! |-------------|----------------------------------------|
18//! | `Exact(m)`  | `{lower: m, upper: m}`                 |
19//! | `AtMost(m)` | `{lower: allow_nothing(), upper: m}`   |
20//! | `AtLeast(m)`| `{lower: m, upper: all_rows()}`        |
21//!
22//! Use [`IndexExprResult::exact`] / [`IndexExprResult::at_most`] /
23//! [`IndexExprResult::at_least`] to construct those shapes, and the
24//! matching [`IndexExprResult::is_exact`] etc. predicates to inspect
25//! them. Intervals that are neither (the "Refined" case — a non-empty
26//! `lower` strictly inside a non-universe `upper`) arise from indices
27//! that can distinguish guaranteed-match from candidate-match rows
28//! within a single search (e.g. a zone map answering `IS NOT NULL`).
29//!
30//! The boolean algebra (`Not` / `BitAnd` / `BitOr`) is elementwise on
31//! the endpoints:
32//!
33//! ```text
34//! !{l, u}                = {!u, !l}
35//! {l1, u1} & {l2, u2}    = {l1 & l2, u1 & u2}
36//! {l1, u1} | {l2, u2}    = {l1 | l2, u1 | u2}
37//! ```
38//!
39//! This works for both the post-`drop_nulls` form ([`IndexExprResult`],
40//! backed by [`RowAddrMask`]) and the during-evaluation form
41//! ([`NullableIndexExprResult`], backed by [`NullableRowAddrMask`]) —
42//! the per-endpoint algebra already implements two-valued and SQL
43//! three-valued logic correctly inside each mask type.
44
45use std::sync::{Arc, LazyLock};
46
47use arrow_array::{Array, RecordBatch, UInt32Array, builder::BinaryBuilder};
48use arrow_schema::{DataType, Field, Schema, SchemaRef};
49use roaring::RoaringBitmap;
50
51use lance_core::{Error, Result};
52
53use crate::mask::{NullableRowAddrMask, RowAddrMask, RowSetOps};
54
55/// Result of an index search before NULL rows are dropped. Each endpoint
56/// is a [`NullableRowAddrMask`] carrying SQL three-valued logic info.
57#[derive(Debug, Clone)]
58pub struct NullableIndexExprResult {
59    /// Rows the index *guarantees* are TRUE.
60    pub lower: NullableRowAddrMask,
61    /// Rows that may be TRUE. Rows outside `upper` are guaranteed to be
62    /// FALSE / NULL (and so not in a `WHERE` answer set).
63    pub upper: NullableRowAddrMask,
64    // O(1) cache for is_exact(). Set by constructors and propagated
65    // elementwise through the boolean algebra.
66    exact: bool,
67}
68
69impl NullableIndexExprResult {
70    /// Precise result — every row in `mask` is in the answer and every
71    /// row outside is not. Equivalent to the old `Exact` variant.
72    pub fn exact(mask: NullableRowAddrMask) -> Self {
73        Self {
74            lower: mask.clone(),
75            upper: mask,
76            exact: true,
77        }
78    }
79
80    /// Upper-bound-only result — rows outside `mask` are guaranteed not
81    /// to match; rows inside may match and require a recheck.
82    /// Equivalent to the old `AtMost` variant.
83    pub fn at_most(mask: NullableRowAddrMask) -> Self {
84        Self {
85            lower: NullableRowAddrMask::allow_nothing(),
86            upper: mask,
87            exact: false,
88        }
89    }
90
91    /// Lower-bound-only result — rows in `mask` are guaranteed to match;
92    /// rows outside may match too and require a recheck. Equivalent to
93    /// the old `AtLeast` variant.
94    pub fn at_least(mask: NullableRowAddrMask) -> Self {
95        Self {
96            lower: mask,
97            upper: NullableRowAddrMask::all_rows(),
98            exact: false,
99        }
100    }
101
102    /// Construct an interval result from lower/upper bounds.
103    ///
104    /// `lower` rows are guaranteed TRUE and `upper` rows may be TRUE, with
105    /// NULL state preserved at both endpoints. Equal endpoints are canonicalized
106    /// to [`Self::exact`] so `is_exact()` remains structural.
107    pub fn new(lower: NullableRowAddrMask, upper: NullableRowAddrMask) -> Self {
108        if lower == upper {
109            Self::exact(lower)
110        } else {
111            Self {
112                lower,
113                upper,
114                exact: false,
115            }
116        }
117    }
118
119    /// True if the result is exact — the answer is precisely the lower
120    /// (== upper) mask.
121    ///
122    /// This is a **structural** check on the canonical form produced by
123    /// the constructors / algebra: an `Exact(m)` built with
124    /// [`Self::exact`] holds equal masks, and elementwise `&` / `|` / `!`
125    /// preserve that. It is not a semantic emptiness test — a
126    /// hand-constructed `IndexExprResult` whose endpoints are
127    /// representationally distinct but semantically equal (e.g.
128    /// `AllowList(universe)` vs `BlockList(empty)`) will report
129    /// `is_exact() == false`. All in-tree code paths construct results
130    /// through the canonical builders, so this is sound in practice.
131    ///
132    /// The three shape predicates are not mutually exclusive — see the
133    /// note on [`Self::is_at_least`] for the precedence convention.
134    pub fn is_exact(&self) -> bool {
135        self.exact
136    }
137
138    /// True if `lower` matches no rows (canonical `AllowList(∅)`) — the
139    /// index gives only an upper bound on the answer.
140    ///
141    /// Like [`Self::is_exact`], this is a structural check on the
142    /// canonical form. See that doc for the caveat.
143    pub fn is_at_most(&self) -> bool {
144        matches!(&self.lower, NullableRowAddrMask::AllowList(set) if set.is_empty())
145    }
146
147    /// True if `upper` covers every row (canonical `BlockList(∅)`) — the
148    /// index gives only a lower bound on the answer.
149    ///
150    /// **Precedence convention** for consumers branching on shape: check
151    /// [`Self::is_exact`] *first* (Exact-of-empty satisfies both
152    /// `is_exact` and `is_at_most`; Exact-of-universe satisfies both
153    /// `is_exact` and `is_at_least`); then `is_at_least`; finally treat
154    /// the residual as `is_at_most` or Refined. The branches in
155    /// `filtered_read::apply_index_to_fragment` follow this order.
156    pub fn is_at_least(&self) -> bool {
157        matches!(&self.upper, NullableRowAddrMask::BlockList(set) if set.is_empty())
158    }
159
160    /// Project NULL rows out of the result.
161    ///
162    /// Under a `WHERE` clause NULL is treated as FALSE, so `drop_nulls`
163    /// folds NULL rows out of the answer at each endpoint.
164    pub fn drop_nulls(self) -> IndexExprResult {
165        IndexExprResult {
166            lower: self.lower.drop_nulls(),
167            upper: self.upper.drop_nulls(),
168            exact: self.exact,
169        }
170    }
171}
172
173impl std::ops::Not for NullableIndexExprResult {
174    type Output = Self;
175
176    fn not(self) -> Self {
177        Self {
178            lower: !self.upper,
179            upper: !self.lower,
180            exact: self.exact,
181        }
182    }
183}
184
185impl std::ops::BitAnd<Self> for NullableIndexExprResult {
186    type Output = Self;
187
188    fn bitand(self, rhs: Self) -> Self {
189        Self {
190            lower: self.lower & rhs.lower,
191            upper: self.upper & rhs.upper,
192            exact: self.exact && rhs.exact,
193        }
194    }
195}
196
197impl std::ops::BitOr<Self> for NullableIndexExprResult {
198    type Output = Self;
199
200    fn bitor(self, rhs: Self) -> Self {
201        Self {
202            lower: self.lower | rhs.lower,
203            upper: self.upper | rhs.upper,
204            exact: self.exact && rhs.exact,
205        }
206    }
207}
208
209/// Result of an index search after NULL rows have been dropped. This is
210/// what the read planner consumes.
211#[derive(Debug, Clone)]
212pub struct IndexExprResult {
213    /// Rows the index *guarantees* are in the answer.
214    pub lower: RowAddrMask,
215    /// Rows that may be in the answer. Rows outside `upper` are
216    /// guaranteed not in the answer.
217    pub upper: RowAddrMask,
218    // O(1) cache for is_exact(). Set by constructors and propagated
219    // elementwise through the boolean algebra.
220    exact: bool,
221}
222
223impl IndexExprResult {
224    /// Precise result — every row in `mask` is in the answer and every
225    /// row outside is not. Equivalent to the old `Exact` variant.
226    pub fn exact(mask: RowAddrMask) -> Self {
227        Self {
228            lower: mask.clone(),
229            upper: mask,
230            exact: true,
231        }
232    }
233
234    /// Upper-bound-only result. Equivalent to the old `AtMost` variant.
235    pub fn at_most(mask: RowAddrMask) -> Self {
236        Self {
237            lower: RowAddrMask::allow_nothing(),
238            upper: mask,
239            exact: false,
240        }
241    }
242
243    /// Lower-bound-only result. Equivalent to the old `AtLeast` variant.
244    pub fn at_least(mask: RowAddrMask) -> Self {
245        Self {
246            lower: mask,
247            upper: RowAddrMask::all_rows(),
248            exact: false,
249        }
250    }
251
252    /// Construct a refined interval result — `lower` rows are guaranteed
253    /// matches and `upper` rows are candidates, with `lower ⊆ upper`.
254    ///
255    /// Use [`Self::exact`] / [`Self::at_most`] / [`Self::at_least`] for
256    /// the three degenerate shapes; this constructor is only needed when
257    /// both endpoints are non-trivial.
258    pub fn new(lower: RowAddrMask, upper: RowAddrMask) -> Self {
259        Self {
260            lower,
261            upper,
262            exact: false,
263        }
264    }
265
266    /// True if the result is exact — the answer is precisely the lower
267    /// (== upper) mask. See [`NullableIndexExprResult::is_exact`] for the
268    /// structural-form caveat and the precedence convention shared with
269    /// [`Self::is_at_most`] / [`Self::is_at_least`].
270    pub fn is_exact(&self) -> bool {
271        self.exact
272    }
273
274    /// True if `lower` matches no rows (canonical `AllowList(∅)`) — the
275    /// index gives only an upper bound on the answer. See
276    /// [`NullableIndexExprResult::is_exact`] for caveats.
277    pub fn is_at_most(&self) -> bool {
278        matches!(&self.lower, RowAddrMask::AllowList(set) if set.is_empty())
279    }
280
281    /// True if `upper` covers every row (canonical `BlockList(∅)`) — the
282    /// index gives only a lower bound on the answer. See
283    /// [`NullableIndexExprResult::is_at_least`] for the precedence
284    /// convention consumers should follow.
285    pub fn is_at_least(&self) -> bool {
286        matches!(&self.upper, RowAddrMask::BlockList(set) if set.is_empty())
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::{NullableRowAddrSet, RowAddrTreeMap};
294
295    fn allow(rows: &[u64]) -> NullableRowAddrMask {
296        NullableRowAddrMask::AllowList(NullableRowAddrSet::new(
297            RowAddrTreeMap::from_iter(rows.iter().copied()),
298            RowAddrTreeMap::new(),
299        ))
300    }
301
302    #[test]
303    fn nullable_index_expr_result_new_canonicalizes_exact() {
304        let lower = allow(&[1, 2]);
305        let result = NullableIndexExprResult::new(lower.clone(), lower.clone());
306
307        assert!(result.is_exact());
308        assert_eq!(result.lower, lower);
309        assert_eq!(result.upper, lower);
310    }
311
312    #[test]
313    fn nullable_index_expr_result_new_preserves_interval() {
314        let lower = allow(&[1, 2]);
315        let upper = allow(&[1, 2, 3]);
316        let result = NullableIndexExprResult::new(lower.clone(), upper.clone());
317
318        assert!(!result.is_exact());
319        assert_eq!(result.lower, lower);
320        assert_eq!(result.upper, upper);
321    }
322}
323
324impl std::ops::Not for IndexExprResult {
325    type Output = Self;
326
327    fn not(self) -> Self {
328        Self {
329            lower: !self.upper,
330            upper: !self.lower,
331            exact: self.exact,
332        }
333    }
334}
335
336impl std::ops::BitAnd<Self> for IndexExprResult {
337    type Output = Self;
338
339    fn bitand(self, rhs: Self) -> Self {
340        Self {
341            lower: self.lower & rhs.lower,
342            upper: self.upper & rhs.upper,
343            exact: self.exact && rhs.exact,
344        }
345    }
346}
347
348impl std::ops::BitOr<Self> for IndexExprResult {
349    type Output = Self;
350
351    fn bitor(self, rhs: Self) -> Self {
352        Self {
353            lower: self.lower | rhs.lower,
354            upper: self.upper | rhs.upper,
355            exact: self.exact && rhs.exact,
356        }
357    }
358}
359
360static TWO_MASK_RESULT_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
361    Arc::new(Schema::new(vec![
362        Field::new("lower", DataType::Binary, true),
363        Field::new("upper", DataType::Binary, true),
364        Field::new("fragments_covered", DataType::Binary, true),
365    ]))
366});
367
368static THREE_VARIANT_RESULT_SCHEMA: LazyLock<SchemaRef> = LazyLock::new(|| {
369    Arc::new(Schema::new(vec![
370        Field::new("result".to_string(), DataType::Binary, true),
371        Field::new("discriminant".to_string(), DataType::UInt32, true),
372        Field::new("fragments_covered".to_string(), DataType::Binary, true),
373    ]))
374});
375
376#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
377pub enum IndexExprResultWireFormat {
378    ThreeVariant, // A legacy format that used AtMost/AtLeast/Exact variants
379    #[default]
380    TwoMask, // The two-mask format with upper and lower
381}
382
383impl IndexExprResultWireFormat {
384    pub fn schema(&self) -> &SchemaRef {
385        match self {
386            Self::ThreeVariant => &THREE_VARIANT_RESULT_SCHEMA,
387            Self::TwoMask => &TWO_MASK_RESULT_SCHEMA,
388        }
389    }
390}
391
392impl IndexExprResult {
393    /// Serialize into the `INDEX_EXPR_RESULT_SCHEMA` record-batch layout used to
394    /// hand scalar-index results to the read planner.
395    #[tracing::instrument(skip_all)]
396    fn serialize_standard(&self, fragments_covered: &RoaringBitmap) -> Result<RecordBatch> {
397        let lower_arr = self.lower.into_arrow()?;
398        let upper_arr = if self.is_exact() {
399            let mut b = BinaryBuilder::new();
400            b.append_null();
401            b.append_null();
402            b.finish()
403        } else {
404            self.upper.into_arrow()?
405        };
406        let mut frags_builder = BinaryBuilder::new();
407        let mut frags_bytes = Vec::with_capacity(fragments_covered.serialized_size());
408        fragments_covered.serialize_into(&mut frags_bytes)?;
409        frags_builder.append_value(frags_bytes);
410        frags_builder.append_null();
411        Ok(RecordBatch::try_new(
412            TWO_MASK_RESULT_SCHEMA.clone(),
413            vec![
414                Arc::new(lower_arr),
415                Arc::new(upper_arr),
416                Arc::new(frags_builder.finish()) as Arc<dyn Array>,
417            ],
418        )?)
419    }
420
421    /// Serialize into the legacy three-variant record-batch layout.
422    ///
423    /// Refined intervals (a non-empty `lower` strictly inside a non-universe `upper`)
424    /// cannot be represented in the legacy encoding and are degraded to `AtMost(upper)`.
425    fn serialize_three_variant(&self, fragments_covered: &RoaringBitmap) -> Result<RecordBatch> {
426        let (mask, discriminant) = if self.is_exact() {
427            (&self.lower, 0u32)
428        } else if self.is_at_most() {
429            (&self.upper, 1)
430        } else if self.is_at_least() {
431            (&self.lower, 2)
432        } else {
433            tracing::warn!(
434                "Legacy serialization of refined index-expr result: degrading to AtMost(upper); \
435                 answer will remain correct but query will be more expensive"
436            );
437            (&self.upper, 1)
438        };
439        let mask_arr = mask.into_arrow()?;
440        let discriminant_arr =
441            Arc::new(UInt32Array::from(vec![discriminant, discriminant])) as Arc<dyn Array>;
442        let mut frags_builder = BinaryBuilder::new();
443        let mut frags_bytes = Vec::with_capacity(fragments_covered.serialized_size());
444        fragments_covered.serialize_into(&mut frags_bytes)?;
445        frags_builder.append_value(frags_bytes);
446        frags_builder.append_null();
447        Ok(RecordBatch::try_new(
448            THREE_VARIANT_RESULT_SCHEMA.clone(),
449            vec![
450                Arc::new(mask_arr),
451                discriminant_arr,
452                Arc::new(frags_builder.finish()) as Arc<dyn Array>,
453            ],
454        )?)
455    }
456
457    pub fn serialize(
458        &self,
459        fragments_covered: &RoaringBitmap,
460        format: IndexExprResultWireFormat,
461    ) -> Result<RecordBatch> {
462        match format {
463            IndexExprResultWireFormat::ThreeVariant => {
464                self.serialize_three_variant(fragments_covered)
465            }
466            IndexExprResultWireFormat::TwoMask => self.serialize_standard(fragments_covered),
467        }
468    }
469
470    /// Deserialize from a record batch produced by [`Self::serialize`].
471    pub fn deserialize(batch: &RecordBatch) -> Result<(Self, RoaringBitmap)> {
472        use arrow_array::cast::AsArray;
473
474        if batch.num_rows() != 2 {
475            return Err(Error::invalid_input_source(
476                format!(
477                    "Expected a batch with exactly 2 rows but there are {} rows",
478                    batch.num_rows()
479                )
480                .into(),
481            ));
482        }
483        if batch.num_columns() != 3 {
484            return Err(Error::invalid_input_source(
485                format!(
486                    "Expected a batch with exactly three columns but there are {} columns",
487                    batch.num_columns()
488                )
489                .into(),
490            ));
491        }
492
493        let first_col_name = batch.schema().field(0).name().clone();
494        let index_result = if first_col_name == "lower" {
495            let lower = RowAddrMask::from_arrow(batch.column(0).as_binary())?;
496            let upper_col = batch.column(1).as_binary::<i32>();
497            if upper_col.is_null(0) && upper_col.is_null(1) {
498                // Null upper column is the serialized form of an exact result.
499                Self::exact(lower)
500            } else {
501                let upper = RowAddrMask::from_arrow(upper_col)?;
502                Self {
503                    lower,
504                    upper,
505                    exact: false,
506                }
507            }
508        } else if first_col_name == "result" {
509            let row_addr_mask = RowAddrMask::from_arrow(batch.column(0).as_binary())?;
510            let match_type = batch
511                .column(1)
512                .as_primitive::<arrow_array::types::UInt32Type>()
513                .values()[0];
514            if match_type == 0 {
515                Self::exact(row_addr_mask)
516            } else if match_type == 1 {
517                Self::at_most(row_addr_mask)
518            } else if match_type == 2 {
519                Self::at_least(row_addr_mask)
520            } else {
521                return Err(Error::internal(format!(
522                    "Unexpected match type: {match_type}"
523                )));
524            }
525        } else {
526            return Err(Error::internal(format!(
527                "Unexpected column name: {first_col_name}"
528            )));
529        };
530
531        let frags_col = batch.column(2).as_binary::<i32>();
532        let fragments = RoaringBitmap::deserialize_from(frags_col.value(0))?;
533
534        Ok((index_result, fragments))
535    }
536}