Skip to main content

lance_index/scalar/
zonemap.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Zone Map Index
5//!
6//! Zone maps are a columnar database technique for predicate pushdown and scan pruning.
7//! They break data into fixed-size chunks called "zones" and maintain summary statistics
8//! (min, max, null count) for each zone. This enables efficient filtering by eliminating
9//! zones that cannot contain matching values.
10//!
11//! Zone maps are "inexact" filters - they can definitively exclude zones but may include
12//! false positives that require rechecking.
13//!
14//!
15use crate::pbold;
16use crate::scalar::expression::{SargableQueryParser, ScalarQueryParser};
17use crate::scalar::registry::{
18    BasicTrainer, ScalarIndexPlugin, TrainingCriteria, TrainingOrdering, TrainingRequest,
19};
20use crate::scalar::seed::IndexSeedWriter;
21use crate::scalar::{
22    BuiltinIndexType, CreatedIndex, IndexFile, SargableQuery, ScalarIndexParams, UpdateCriteria,
23    compute_next_prefix,
24};
25use lance_arrow_stats::StatisticsAccumulator;
26use lance_core::cache::{LanceCache, WeakLanceCache};
27use lance_core::utils::row_addr_remap::RowAddrRemap;
28use serde::{Deserialize, Serialize};
29use std::any::Any;
30use std::sync::LazyLock;
31
32use arrow_array::{
33    ArrayRef, RecordBatch, UInt32Array, UInt64Array, new_empty_array, new_null_array,
34};
35use arrow_schema::{DataType, Field};
36use datafusion::execution::SendableRecordBatchStream;
37use datafusion_common::ScalarValue;
38use lance_select::RowAddrTreeMap;
39use std::{collections::HashMap, sync::Arc};
40
41use super::{AnyQuery, IndexStore, MetricsCollector, ScalarIndex, SearchResult};
42use crate::scalar::RowIdRemapper;
43use crate::{Index, IndexType};
44use async_trait::async_trait;
45use lance_core::Error;
46use lance_core::Result;
47use lance_core::deepsize::DeepSizeOf;
48use roaring::RoaringBitmap;
49
50use super::zoned::{ZoneBound, ZoneProcessor, ZoneTrainer, rebuild_zones, search_zones};
51const ROWS_PER_ZONE_DEFAULT: u64 = 8192; // 1 zone every two batches
52
53const ZONEMAP_FILENAME: &str = "zonemap.lance";
54const ZONEMAP_SIZE_META_KEY: &str = "rows_per_zone";
55const NULL_BITMAP_META_KEY: &str = "null_bitmap";
56const ZONEMAP_INDEX_VERSION: u32 = 0;
57
58/// Basic stats about zonemap index
59#[derive(Debug, PartialEq, Clone)]
60pub(crate) struct ZoneMapStatistics {
61    min: ScalarValue,
62    max: ScalarValue,
63    null_count: u32,
64    // only apply to float type
65    nan_count: u32,
66    // Bound of this zone within the fragment. Persisted as three separate columns
67    // (fragment_id, zone_start, zone_length) in the index file.
68    bound: ZoneBound,
69}
70
71impl DeepSizeOf for ZoneMapStatistics {
72    fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
73        // Estimate sizes for ScalarValue
74        let min_size = self.min.size() - std::mem::size_of::<ScalarValue>();
75        let max_size = self.max.size() - std::mem::size_of::<ScalarValue>();
76
77        min_size + max_size
78    }
79}
80
81impl AsRef<ZoneBound> for ZoneMapStatistics {
82    fn as_ref(&self) -> &ZoneBound {
83        &self.bound
84    }
85}
86
87/// ZoneMap index
88/// At high level it's a columnar database technique for predicate push down and scan pruning.
89/// It breaks data into fixed-size chunks called `zones` and store summary statistics(min, max, null_count,
90/// nan_count, fragment_id, local_row_offset) for each zone. It enables efficient filtering by skipping zones that do not contain matching values
91///
92/// This is an inexact filter, similar to a bloom filter. It can return false positives that require rechecking.
93///
94/// Note that it cannot return false negatives.
95/// Input:
96/// * Fragment 1: - 10 rows   -> 0  -> 9
97/// * Fragment 2: - 7 rows    -> 10 -> 16
98/// * Fragment 3: - 4 rows    -> 20 -> 23
99/// * Zone size AKA “rows_per_zone” (from user) - 5
100///
101/// Output:
102/// fragment id | min | max | zone_length
103/// 1           | 0   |  4  | 5
104/// 1           | 5   |  9  | 5
105/// 2           | 10  | 14  | 5
106/// 2           | 15  | 16  | 2
107/// 3           | 20  | 23  | 4
108pub struct ZoneMapIndex {
109    zones: Vec<ZoneMapStatistics>,
110    data_type: DataType,
111    // The maximum rows per zone provided by user
112    rows_per_zone: u64,
113    use_seeds: bool,
114    store: Arc<dyn IndexStore>,
115    fri: Option<Arc<dyn RowIdRemapper>>,
116    index_cache: WeakLanceCache,
117    // Exact set of null row addresses across all zones; None when loaded from an
118    // older index that did not persist this bitmap.
119    null_rows: Option<RowAddrTreeMap>,
120}
121
122impl std::fmt::Debug for ZoneMapIndex {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("ZoneMapIndex")
125            .field("zones", &self.zones)
126            .field("data_type", &self.data_type)
127            .field("rows_per_zone", &self.rows_per_zone)
128            .field("use_seeds", &self.use_seeds)
129            .field("store", &self.store)
130            .field("fri", &self.fri)
131            .field("index_cache", &self.index_cache)
132            .finish()
133    }
134}
135
136impl DeepSizeOf for ZoneMapIndex {
137    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
138        self.zones.deep_size_of_children(context) + self.null_rows.deep_size_of_children(context)
139    }
140}
141
142impl ZoneMapIndex {
143    /// Returns the rows-per-zone parameter for this index.
144    pub fn rows_per_zone(&self) -> u64 {
145        self.rows_per_zone
146    }
147
148    fn scalar_is_nan(value: &ScalarValue) -> bool {
149        match value {
150            ScalarValue::Float16(Some(value)) => value.is_nan(),
151            ScalarValue::Float32(Some(value)) => value.is_nan(),
152            ScalarValue::Float64(Some(value)) => value.is_nan(),
153            _ => false,
154        }
155    }
156
157    /// Returns true if the zone has a non-null, non-NaN min value.
158    fn zone_has_finite_min(zone: &ZoneMapStatistics) -> bool {
159        !(zone.min.is_null() || Self::scalar_is_nan(&zone.min))
160    }
161
162    /// Returns true if both min and max are non-null / non-NaN.
163    fn zone_has_finite_extrema(zone: &ZoneMapStatistics) -> bool {
164        Self::zone_has_finite_min(zone) && !(zone.max.is_null() || Self::scalar_is_nan(&zone.max))
165    }
166
167    /// Global `[min, max]` folded across one or more ZoneMap segments (the
168    /// disjoint per-column segments of a multi-segment index), without a scan.
169    ///
170    /// `None` when no zone has a finite bound, or when any zone's `max` is NaN:
171    /// `ScalarValue`'s total order ranks NaN above every finite value, so a
172    /// NaN-bearing zone hides its true finite max and no sound upper bound exists
173    /// without a scan — folding only the finite maxes would yield a *subset* that
174    /// prunes live rows. Folding raw zones (not each segment's `value_range`)
175    /// keeps this exact across segments.
176    ///
177    /// Otherwise the range is a superset of the segments' live values,
178    /// conservative under deletion vectors: safe to prune with, not guaranteed
179    /// tight. The caller must ensure the segments jointly cover every live
180    /// fragment.
181    pub fn value_range_over<'a>(
182        segments: impl IntoIterator<Item = &'a Self>,
183    ) -> Option<(ScalarValue, ScalarValue)> {
184        let mut min: Option<&ScalarValue> = None;
185        let mut max: Option<&ScalarValue> = None;
186        for zone in segments.into_iter().flat_map(|seg| seg.zones.iter()) {
187            if Self::scalar_is_nan(&zone.max) {
188                return None;
189            }
190            if Self::scalar_is_finite_bound(&zone.min)
191                && min.is_none_or(|cur| zone.min.partial_cmp(cur).is_some_and(|o| o.is_lt()))
192            {
193                min = Some(&zone.min);
194            }
195            if Self::scalar_is_finite_bound(&zone.max)
196                && max.is_none_or(|cur| zone.max.partial_cmp(cur).is_some_and(|o| o.is_gt()))
197            {
198                max = Some(&zone.max);
199            }
200        }
201        Some((min?.clone(), max?.clone()))
202    }
203
204    /// A scalar usable as a global-range bound: non-null and, for floats, non-NaN.
205    fn scalar_is_finite_bound(v: &ScalarValue) -> bool {
206        !v.is_null() && !Self::scalar_is_nan(v)
207    }
208
209    /// Evaluates whether a zone could potentially contain values matching the query.
210    ///
211    /// NaN query values use the explicit `nan_count`. For finite query values,
212    /// `ScalarValue` total ordering keeps finite values below a stored NaN max,
213    /// so zones with finite values plus NaNs remain conservative false positives.
214    fn evaluate_zone_against_query(
215        &self,
216        zone: &ZoneMapStatistics,
217        query: &SargableQuery,
218    ) -> Result<bool> {
219        use std::ops::Bound;
220
221        match query {
222            SargableQuery::IsNull() => {
223                // Zone contains matching values if it has any null values
224                Ok(zone.null_count > 0)
225            }
226            SargableQuery::Equals(target) => {
227                // Zone contains matching values if target falls within [min, max] range
228                // Handle null values - if target is null, check null_count
229                if target.is_null() {
230                    return Ok(zone.null_count > 0);
231                }
232
233                // Handle NaN values - if target is NaN, check nan_count
234                let is_nan = match target {
235                    ScalarValue::Float16(Some(f)) => f.is_nan(),
236                    ScalarValue::Float32(Some(f)) => f.is_nan(),
237                    ScalarValue::Float64(Some(f)) => f.is_nan(),
238                    _ => false,
239                };
240
241                if is_nan {
242                    return Ok(zone.nan_count > 0);
243                }
244
245                if !Self::zone_has_finite_min(zone) {
246                    return Ok(false);
247                }
248
249                Ok(target >= &zone.min && target <= &zone.max)
250            }
251            SargableQuery::Range(start, end) => {
252                // Zone overlaps with query range if there's any intersection between
253                // the zone's [min, max] and the query's range
254                if !Self::zone_has_finite_min(zone) {
255                    return Ok(false);
256                }
257
258                let zone_min = &zone.min;
259                let zone_max = &zone.max;
260
261                let start_check = match start {
262                    Bound::Unbounded => true,
263                    Bound::Included(s) => {
264                        // Handle NaN in range bounds - NaN is greater than all finite values
265                        match s {
266                            ScalarValue::Float16(Some(f)) => {
267                                if f.is_nan() {
268                                    return Ok(zone.nan_count > 0);
269                                }
270                            }
271                            ScalarValue::Float32(Some(f)) => {
272                                if f.is_nan() {
273                                    return Ok(zone.nan_count > 0);
274                                }
275                            }
276                            ScalarValue::Float64(Some(f)) if f.is_nan() => {
277                                return Ok(zone.nan_count > 0);
278                            }
279                            _ => {}
280                        }
281                        // Handle the case where zone_max is NaN
282                        // If zone_max is NaN, the zone contains both finite values and NaN
283                        // Since we don't know the actual max, we'll be conservative and include the zone
284                        match zone_max {
285                            ScalarValue::Float16(Some(f)) if f.is_nan() => true,
286                            ScalarValue::Float32(Some(f)) if f.is_nan() => true,
287                            ScalarValue::Float64(Some(f)) if f.is_nan() => true,
288                            _ => zone_max >= s,
289                        }
290                    }
291                    Bound::Excluded(s) => {
292                        // Handle NaN in range bounds
293                        match s {
294                            ScalarValue::Float16(Some(f)) => {
295                                if f.is_nan() {
296                                    return Ok(false); // Nothing is greater than NaN
297                                }
298                            }
299                            ScalarValue::Float32(Some(f)) => {
300                                if f.is_nan() {
301                                    return Ok(false); // Nothing is greater than NaN
302                                }
303                            }
304                            ScalarValue::Float64(Some(f)) if f.is_nan() => {
305                                return Ok(false); // Nothing is greater than NaN
306                            }
307                            _ => {}
308                        }
309                        zone_max > s
310                    }
311                };
312
313                let end_check = match end {
314                    Bound::Unbounded => true,
315                    Bound::Included(e) => {
316                        // Handle NaN in range bounds
317                        match e {
318                            ScalarValue::Float16(Some(f)) => {
319                                if f.is_nan() {
320                                    // NaN is included, so check if zone has NaN values or finite values
321                                    return Ok(zone.nan_count > 0 || zone_min <= e);
322                                }
323                            }
324                            ScalarValue::Float32(Some(f)) => {
325                                if f.is_nan() {
326                                    return Ok(zone.nan_count > 0 || zone_min <= e);
327                                }
328                            }
329                            ScalarValue::Float64(Some(f)) if f.is_nan() => {
330                                return Ok(zone.nan_count > 0 || zone_min <= e);
331                            }
332                            _ => {}
333                        }
334                        zone_min <= e
335                    }
336                    Bound::Excluded(e) => {
337                        // Handle NaN in range bounds
338                        match e {
339                            ScalarValue::Float16(Some(f)) => {
340                                if f.is_nan() {
341                                    // Everything is less than NaN, so include all finite values
342                                    return Ok(true);
343                                }
344                            }
345                            ScalarValue::Float32(Some(f)) => {
346                                if f.is_nan() {
347                                    return Ok(true);
348                                }
349                            }
350                            ScalarValue::Float64(Some(f)) if f.is_nan() => {
351                                return Ok(true);
352                            }
353                            _ => {}
354                        }
355                        zone_min < e
356                    }
357                };
358
359                Ok(start_check && end_check)
360            }
361            SargableQuery::IsIn(values) => {
362                // Zone contains matching values if any value in the set falls within [min, max]
363                Ok(values.iter().any(|value| {
364                    if value.is_null() {
365                        zone.null_count > 0
366                    } else {
367                        match value {
368                            ScalarValue::Float16(Some(f)) => {
369                                if f.is_nan() {
370                                    zone.nan_count > 0
371                                } else if !Self::zone_has_finite_min(zone) {
372                                    false
373                                } else {
374                                    value >= &zone.min && value <= &zone.max
375                                }
376                            }
377                            ScalarValue::Float32(Some(f)) => {
378                                if f.is_nan() {
379                                    zone.nan_count > 0
380                                } else if !Self::zone_has_finite_min(zone) {
381                                    false
382                                } else {
383                                    value >= &zone.min && value <= &zone.max
384                                }
385                            }
386                            ScalarValue::Float64(Some(f)) => {
387                                if f.is_nan() {
388                                    zone.nan_count > 0
389                                } else if !Self::zone_has_finite_min(zone) {
390                                    false
391                                } else {
392                                    value >= &zone.min && value <= &zone.max
393                                }
394                            }
395                            _ => {
396                                Self::zone_has_finite_extrema(zone)
397                                    && value >= &zone.min
398                                    && value <= &zone.max
399                            }
400                        }
401                    }
402                }))
403            }
404            SargableQuery::FullTextSearch(_) => Err(Error::not_supported_source(
405                "full text search is not supported for zonemap indexes".into(),
406            )),
407            SargableQuery::LikePrefix(prefix) => {
408                // For prefix matching, a zone can match if:
409                // - zone.max >= prefix (there could be values >= prefix)
410                // - zone.min < next_prefix (there could be values < next_prefix)
411                //
412                // For example, prefix "foo":
413                // - Zone [aaa, azz]: max="azz" < "foo", so no match
414                // - Zone [fa, foz]: min="fa" < "fop", max="foz" >= "foo", so potential match
415                // - Zone [fop, fzz]: min="fop" >= "fop", so no match
416
417                let prefix_str = match prefix {
418                    ScalarValue::Utf8(Some(s)) => s.as_str(),
419                    ScalarValue::LargeUtf8(Some(s)) => s.as_str(),
420                    _ => return Ok(true), // Conservative: include zone if not a string prefix
421                };
422
423                // Empty prefix matches everything
424                if prefix_str.is_empty() {
425                    return Ok(true);
426                }
427
428                // Check zone.max >= prefix
429                let max_check = &zone.max >= prefix;
430                if !max_check {
431                    return Ok(false);
432                }
433
434                // Compute next_prefix by incrementing the last byte
435                // If the prefix ends with 0xFF bytes, we need to handle overflow
436                let next_prefix = compute_next_prefix(prefix_str);
437
438                match next_prefix {
439                    Some(next) => {
440                        // Check zone.min < next_prefix
441                        let next_scalar = match prefix {
442                            ScalarValue::Utf8(_) => ScalarValue::Utf8(Some(next)),
443                            ScalarValue::LargeUtf8(_) => ScalarValue::LargeUtf8(Some(next)),
444                            _ => return Ok(true),
445                        };
446                        Ok(zone.min < next_scalar)
447                    }
448                    None => {
449                        // No upper bound (prefix is all 0xFF), so any zone with max >= prefix matches
450                        Ok(true)
451                    }
452                }
453            }
454        }
455    }
456
457    /// Load the scalar index from storage
458    async fn load(
459        store: Arc<dyn IndexStore>,
460        fri: Option<Arc<dyn RowIdRemapper>>,
461        index_cache: &LanceCache,
462        use_seeds: bool,
463    ) -> Result<Arc<Self>>
464    where
465        Self: Sized,
466    {
467        let index_file = store.open_index_file(ZONEMAP_FILENAME).await?;
468        let zone_maps = index_file
469            .read_range(0..index_file.num_rows(), None)
470            .await?;
471        let file_schema = index_file.schema();
472
473        let rows_per_zone: u64 = file_schema
474            .metadata
475            .get(ZONEMAP_SIZE_META_KEY)
476            .and_then(|bs| bs.parse().ok())
477            .unwrap_or(ROWS_PER_ZONE_DEFAULT);
478
479        let null_rows = if let Some(idx_str) = file_schema.metadata.get(NULL_BITMAP_META_KEY) {
480            let idx = idx_str.parse::<u32>().map_err(|e| {
481                Error::invalid_input(format!("invalid null bitmap buffer index: {e}"))
482            })?;
483            let bytes = index_file.read_global_buffer(idx).await?;
484            Some(RowAddrTreeMap::deserialize_from(bytes.as_ref())?)
485        } else {
486            None
487        };
488
489        Ok(Arc::new(Self::try_from_serialized(
490            zone_maps,
491            store,
492            fri,
493            index_cache,
494            rows_per_zone,
495            null_rows,
496            use_seeds,
497        )?))
498    }
499
500    fn try_from_serialized(
501        data: RecordBatch,
502        store: Arc<dyn IndexStore>,
503        fri: Option<Arc<dyn RowIdRemapper>>,
504        index_cache: &LanceCache,
505        rows_per_zone: u64,
506        null_rows: Option<RowAddrTreeMap>,
507        use_seeds: bool,
508    ) -> Result<Self> {
509        // The RecordBatch should have columns: min, max, null_count
510        let min_col = data
511            .column_by_name("min")
512            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'min' column"))?;
513        let max_col = data
514            .column_by_name("max")
515            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'max' column"))?;
516        let null_count_col = data
517            .column_by_name("null_count")
518            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'null_count' column"))?
519            .as_any()
520            .downcast_ref::<arrow_array::UInt32Array>()
521            .ok_or_else(|| {
522                Error::invalid_input("ZoneMapIndex: 'null_count' column is not UInt32")
523            })?;
524        let nan_count_col = data
525            .column_by_name("nan_count")
526            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'nan_count' column"))?
527            .as_any()
528            .downcast_ref::<arrow_array::UInt32Array>()
529            .ok_or_else(|| {
530                Error::invalid_input("ZoneMapIndex: 'nan_count' column is not UInt32")
531            })?;
532        let zone_length = data
533            .column_by_name("zone_length")
534            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'zone_length' column"))?
535            .as_any()
536            .downcast_ref::<arrow_array::UInt64Array>()
537            .ok_or_else(|| {
538                Error::invalid_input("ZoneMapIndex: 'zone_length' column is not UInt64")
539            })?;
540
541        let fragment_id_col = data
542            .column_by_name("fragment_id")
543            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'fragment_id' column"))?
544            .as_any()
545            .downcast_ref::<arrow_array::UInt64Array>()
546            .ok_or_else(|| {
547                Error::invalid_input("ZoneMapIndex: 'fragment_id' column is not UInt64")
548            })?;
549
550        let zone_start_col = data
551            .column_by_name("zone_start")
552            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'zone_start' column"))?
553            .as_any()
554            .downcast_ref::<arrow_array::UInt64Array>()
555            .ok_or_else(|| {
556                Error::invalid_input("ZoneMapIndex: 'zone_start' column is not UInt64")
557            })?;
558
559        let data_type = min_col.data_type().clone();
560
561        if data.num_rows() == 0 {
562            return Ok(Self {
563                zones: Vec::new(),
564                data_type,
565                rows_per_zone,
566                use_seeds,
567                store,
568                fri,
569                index_cache: WeakLanceCache::from(index_cache),
570                null_rows,
571            });
572        }
573
574        let num_zones = data.num_rows();
575        let mut zones = Vec::with_capacity(num_zones);
576
577        for i in 0..num_zones {
578            let min = ScalarValue::try_from_array(min_col, i)?;
579            let max = ScalarValue::try_from_array(max_col, i)?;
580            let null_count = null_count_col.value(i);
581            let nan_count = nan_count_col.value(i);
582            zones.push(ZoneMapStatistics {
583                min,
584                max,
585                null_count,
586                nan_count,
587                bound: ZoneBound {
588                    fragment_id: fragment_id_col.value(i),
589                    start: zone_start_col.value(i),
590                    length: zone_length.value(i) as usize,
591                },
592            });
593        }
594
595        Ok(Self {
596            zones,
597            data_type,
598            rows_per_zone,
599            use_seeds,
600            store,
601            fri,
602            index_cache: WeakLanceCache::from(index_cache),
603            null_rows,
604        })
605    }
606}
607
608#[async_trait]
609impl Index for ZoneMapIndex {
610    fn as_any(&self) -> &dyn Any {
611        self
612    }
613
614    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
615        self
616    }
617
618    async fn prewarm(&self) -> Result<()> {
619        // Not much to prewarm
620        Ok(())
621    }
622
623    fn statistics(&self) -> Result<serde_json::Value> {
624        Ok(serde_json::json!({
625            "num_zones": self.zones.len(),
626            "rows_per_zone": self.rows_per_zone,
627        }))
628    }
629
630    fn index_type(&self) -> IndexType {
631        IndexType::ZoneMap
632    }
633
634    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
635        let mut frag_ids = RoaringBitmap::new();
636
637        // Loop through zones and add unique fragment IDs to the bitmap
638        for zone in &self.zones {
639            frag_ids.insert(zone.bound.fragment_id as u32);
640        }
641
642        Ok(frag_ids)
643    }
644}
645
646#[async_trait]
647impl ScalarIndex for ZoneMapIndex {
648    async fn search(
649        &self,
650        query: &dyn AnyQuery,
651        metrics: &dyn MetricsCollector,
652    ) -> Result<SearchResult> {
653        let query = query.as_any().downcast_ref::<SargableQuery>().unwrap();
654        if let SargableQuery::IsNull() = query
655            && let Some(null_rows) = &self.null_rows
656        {
657            return Ok(SearchResult::exact(null_rows.clone()));
658        }
659
660        search_zones(&self.zones, metrics, |zone| {
661            self.evaluate_zone_against_query(zone, query)
662        })
663    }
664
665    fn results_are_row_addresses(&self) -> bool {
666        true
667    }
668
669    fn can_remap(&self) -> bool {
670        false
671    }
672
673    /// Remap the row ids, creating a new remapped version of this index in `dest_store`
674    async fn remap(
675        &self,
676        _mapping: &RowAddrRemap,
677        _dest_store: &dyn IndexStore,
678    ) -> Result<CreatedIndex> {
679        Err(Error::invalid_input_source(
680            "ZoneMapIndex does not support remap".into(),
681        ))
682    }
683
684    /// Add the new data , creating an updated version of the index in `dest_store`
685    async fn update(
686        &self,
687        new_data: SendableRecordBatchStream,
688        dest_store: &dyn IndexStore,
689        _old_data_filter: Option<super::OldIndexDataFilter>,
690    ) -> Result<CreatedIndex> {
691        // Train new zones for the incoming data stream
692        let schema = new_data.schema();
693        let value_type = schema.field(0).data_type().clone();
694
695        let options = ZoneMapIndexBuilderParams::new(self.rows_per_zone);
696        let processor = ZoneMapProcessor::new(value_type.clone())?;
697        let trainer = ZoneTrainer::new(processor, self.rows_per_zone)?;
698        let (updated_zones, new_null_rows) = rebuild_zones(&self.zones, trainer, new_data).await?;
699
700        // Merge existing and new null rows.  If the existing index had no null bitmap
701        // (legacy format — null positions unknown), preserve that None: updating cannot
702        // recover the missing information, and claiming the result has zero nulls would
703        // be a false negative.  Only a full retrain produces a fresh, complete bitmap.
704        let merged_null_rows = self.null_rows.as_ref().map(|existing| {
705            let mut merged = existing.clone();
706            merged |= &new_null_rows;
707            merged
708        });
709
710        // Serialize the combined zones back into the index file
711        let mut builder = ZoneMapIndexBuilder::try_new(options, self.data_type.clone())?;
712        builder.options.rows_per_zone = self.rows_per_zone;
713        builder.maps = updated_zones;
714        builder.null_rows = merged_null_rows;
715        let files = builder.write_index(dest_store).await?;
716
717        Ok(CreatedIndex {
718            index_details: make_zone_map_index_details(self.rows_per_zone, self.use_seeds),
719            index_version: ZONEMAP_INDEX_VERSION,
720            files,
721        })
722    }
723
724    fn update_criteria(&self) -> UpdateCriteria {
725        UpdateCriteria::only_new_data(
726            TrainingCriteria::new(TrainingOrdering::Addresses).with_row_addr(),
727        )
728    }
729
730    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
731        let params = serde_json::to_value(ZoneMapIndexBuilderParams {
732            rows_per_zone: self.rows_per_zone,
733            use_seeds: Some(self.use_seeds),
734        })?;
735        Ok(ScalarIndexParams::for_builtin(BuiltinIndexType::ZoneMap).with_params(&params))
736    }
737
738    /// Single-segment `[min, max]` folded from this index's zones; see
739    /// [`value_range_over`](Self::value_range_over) for the full contract.
740    fn value_range(&self) -> Option<(ScalarValue, ScalarValue)> {
741        Self::value_range_over([self])
742    }
743}
744
745impl ZoneMapIndex {
746    async fn try_update_with_seeds(
747        &self,
748        seeds: &[crate::scalar::seed::FragmentSeed],
749        dest_store: &dyn IndexStore,
750    ) -> Result<Option<CreatedIndex>> {
751        let mut new_zones = self.zones.clone();
752        for seed in seeds {
753            let mut zones = ZoneMapSeedWriter::deserialize_seed(
754                seed.fragment_id,
755                &seed.bytes,
756                self.rows_per_zone,
757            )?;
758            new_zones.append(&mut zones);
759        }
760        new_zones.sort_by_key(|z| (z.bound.fragment_id, z.bound.start));
761
762        let mut builder = ZoneMapIndexBuilder::try_new(
763            ZoneMapIndexBuilderParams::new(self.rows_per_zone),
764            self.data_type.clone(),
765        )?;
766        builder.maps = new_zones;
767        let files = builder.write_index(dest_store).await?;
768
769        Ok(Some(CreatedIndex {
770            index_details: make_zone_map_index_details(self.rows_per_zone, self.use_seeds),
771            index_version: ZONEMAP_INDEX_VERSION,
772            files,
773        }))
774    }
775}
776
777/// Merge caller-selected ZoneMap segments into one self-contained segment.
778pub async fn merge_zonemap_indices(
779    source_indices: &[&ZoneMapIndex],
780    dest_store: &dyn IndexStore,
781    fragment_filter: &RoaringBitmap,
782) -> Result<CreatedIndex> {
783    let first = source_indices.first().ok_or_else(|| {
784        Error::invalid_input("merge_zonemap_indices requires at least one source index")
785    })?;
786    let rows_per_zone = first.rows_per_zone;
787    let use_seeds = first.use_seeds;
788    let data_type = first.data_type.clone();
789
790    let mut zones = Vec::new();
791    let mut merged_null_rows = RowAddrTreeMap::new();
792    let mut any_missing_bitmap = false;
793    for source in source_indices {
794        if source.rows_per_zone != rows_per_zone {
795            return Err(Error::invalid_input(format!(
796                "cannot merge ZoneMap segments with different rows_per_zone values: {} and {}",
797                rows_per_zone, source.rows_per_zone
798            )));
799        }
800        if source.data_type != data_type {
801            return Err(Error::invalid_input(format!(
802                "cannot merge ZoneMap segments with different value types: {:?} and {:?}",
803                data_type, source.data_type
804            )));
805        }
806        zones.extend(
807            source
808                .zones
809                .iter()
810                .filter(|zone| {
811                    u32::try_from(zone.bound.fragment_id)
812                        .is_ok_and(|fragment_id| fragment_filter.contains(fragment_id))
813                })
814                .cloned(),
815        );
816        match &source.null_rows {
817            Some(null_rows) => {
818                let mut filtered = null_rows.clone();
819                filtered.retain_fragments(fragment_filter.iter());
820                merged_null_rows |= &filtered;
821            }
822            None => any_missing_bitmap = true,
823        }
824    }
825    zones.sort_by_key(|zone| (zone.bound.fragment_id, zone.bound.start));
826
827    let mut builder =
828        ZoneMapIndexBuilder::try_new(ZoneMapIndexBuilderParams::new(rows_per_zone), data_type)?;
829    builder.maps = zones;
830    if !any_missing_bitmap {
831        builder.null_rows = Some(merged_null_rows);
832    }
833    let files = builder.write_index(dest_store).await?;
834
835    Ok(CreatedIndex {
836        index_details: make_zone_map_index_details(rows_per_zone, use_seeds),
837        index_version: ZONEMAP_INDEX_VERSION,
838        files,
839    })
840}
841
842fn default_rows_per_zone() -> u64 {
843    *DEFAULT_ROWS_PER_ZONE
844}
845
846#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct ZoneMapIndexBuilderParams {
848    #[serde(default = "default_rows_per_zone")]
849    rows_per_zone: u64,
850    /// Whether to embed per-fragment seed buffers in data files for use during
851    /// incremental index updates. `None` means auto-detect based on column type
852    /// (see [`default_use_seeds`]). Resolved to a concrete `bool` during
853    /// training in [`ZoneMapIndexPlugin::new_training_request`].
854    #[serde(default)]
855    use_seeds: Option<bool>,
856}
857
858static DEFAULT_ROWS_PER_ZONE: LazyLock<u64> = LazyLock::new(|| {
859    std::env::var("LANCE_ZONEMAP_DEFAULT_ROWS_PER_ZONE")
860        .unwrap_or_else(|_| (ROWS_PER_ZONE_DEFAULT).to_string())
861        .parse()
862        .expect("failed to parse LANCE_ZONEMAP_DEFAULT_ROWS_PER_ZONE")
863});
864
865impl Default for ZoneMapIndexBuilderParams {
866    fn default() -> Self {
867        Self {
868            rows_per_zone: *DEFAULT_ROWS_PER_ZONE,
869            use_seeds: None,
870        }
871    }
872}
873
874impl ZoneMapIndexBuilderParams {
875    pub fn new(rows_per_zone: u64) -> Self {
876        Self {
877            rows_per_zone,
878            use_seeds: None,
879        }
880    }
881
882    pub fn rows_per_zone(&self) -> u64 {
883        self.rows_per_zone
884    }
885}
886
887// A builder for zonemap index
888pub struct ZoneMapIndexBuilder {
889    options: ZoneMapIndexBuilderParams,
890
891    items_type: DataType,
892    maps: Vec<ZoneMapStatistics>,
893    // None means "legacy index — null positions unknown"; Some means a complete bitmap.
894    // write_index omits the null-bitmap global buffer when this is None, preserving the
895    // legacy format so that downstream searches remain conservative.
896    null_rows: Option<RowAddrTreeMap>,
897}
898
899impl ZoneMapIndexBuilder {
900    pub fn try_new(options: ZoneMapIndexBuilderParams, items_type: DataType) -> Result<Self> {
901        Ok(Self {
902            options,
903            items_type,
904            maps: Vec::new(),
905            null_rows: None,
906        })
907    }
908
909    /// Train the builder using the shared zone trainer.  The input stream must contain
910    /// the value column followed by `_rowaddr`, matching the dataset scan order enforced
911    /// by the scalar index registry.
912    pub async fn train(&mut self, batches_source: SendableRecordBatchStream) -> Result<()> {
913        let processor = ZoneMapProcessor::new(self.items_type.clone())?;
914        let trainer = ZoneTrainer::new(processor, self.options.rows_per_zone)?;
915        let (maps, null_rows) = trainer.train(batches_source).await?;
916        self.maps = maps;
917        self.null_rows = Some(null_rows);
918        Ok(())
919    }
920
921    fn zonemap_stats_as_batch(&self) -> Result<RecordBatch> {
922        // Flush self.maps as a RecordBatch
923        let mins = if self.maps.is_empty() {
924            new_empty_array(&self.items_type)
925        } else {
926            ScalarValue::iter_to_array(self.maps.iter().map(|stat| stat.min.clone()))?
927        };
928        let maxs = if self.maps.is_empty() {
929            new_empty_array(&self.items_type)
930        } else {
931            ScalarValue::iter_to_array(self.maps.iter().map(|stat| stat.max.clone()))?
932        };
933        let null_counts =
934            UInt32Array::from_iter_values(self.maps.iter().map(|stat| stat.null_count));
935
936        let nan_counts = UInt32Array::from_iter_values(self.maps.iter().map(|stat| stat.nan_count));
937
938        let fragment_ids =
939            UInt64Array::from_iter_values(self.maps.iter().map(|stat| stat.bound.fragment_id));
940
941        let zone_lengths =
942            UInt64Array::from_iter_values(self.maps.iter().map(|stat| stat.bound.length as u64));
943
944        let zone_starts =
945            UInt64Array::from_iter_values(self.maps.iter().map(|stat| stat.bound.start));
946
947        let schema = Arc::new(arrow_schema::Schema::new(vec![
948            // min and max can be null if the entire batch is null values
949            Field::new("min", self.items_type.clone(), true),
950            Field::new("max", self.items_type.clone(), true),
951            Field::new("null_count", DataType::UInt32, false),
952            Field::new("nan_count", DataType::UInt32, false),
953            Field::new("fragment_id", DataType::UInt64, false),
954            Field::new("zone_start", DataType::UInt64, false),
955            Field::new("zone_length", DataType::UInt64, false),
956        ]));
957
958        let columns: Vec<ArrayRef> = vec![
959            mins,
960            maxs,
961            Arc::new(null_counts) as ArrayRef,
962            Arc::new(nan_counts) as ArrayRef,
963            Arc::new(fragment_ids) as ArrayRef,
964            Arc::new(zone_starts) as ArrayRef,
965            Arc::new(zone_lengths) as ArrayRef,
966        ];
967        Ok(RecordBatch::try_new(schema, columns)?)
968    }
969
970    pub async fn write_index(self, index_store: &dyn IndexStore) -> Result<Vec<IndexFile>> {
971        let record_batch = self.zonemap_stats_as_batch()?;
972
973        let mut file_schema = record_batch.schema().as_ref().clone();
974        file_schema.metadata.insert(
975            ZONEMAP_SIZE_META_KEY.to_string(),
976            self.options.rows_per_zone.to_string(),
977        );
978
979        let mut index_file = index_store
980            .new_index_file(ZONEMAP_FILENAME, Arc::new(file_schema))
981            .await?;
982        index_file.write_record_batch(record_batch).await?;
983
984        let zonemap_file = if let Some(null_rows) = self.null_rows {
985            let mut null_bitmap_bytes = Vec::with_capacity(null_rows.serialized_size());
986            null_rows.serialize_into(&mut null_bitmap_bytes)?;
987            let null_bitmap_idx = index_file
988                .add_global_buffer(bytes::Bytes::from(null_bitmap_bytes))
989                .await?;
990            index_file
991                .finish_with_metadata(HashMap::from([(
992                    NULL_BITMAP_META_KEY.to_string(),
993                    null_bitmap_idx.to_string(),
994                )]))
995                .await?
996        } else {
997            index_file.finish_with_metadata(HashMap::new()).await?
998        };
999
1000        Ok(vec![zonemap_file])
1001    }
1002}
1003
1004/// Index-specific processor that computes min/max statistics for each zone while the
1005/// trainer takes care of chunking and fragment boundaries.
1006#[derive(Debug)]
1007struct ZoneMapProcessor {
1008    data_type: DataType,
1009    statistics: StatisticsAccumulator,
1010}
1011
1012impl ZoneMapProcessor {
1013    fn new(data_type: DataType) -> Result<Self> {
1014        Ok(Self {
1015            statistics: StatisticsAccumulator::new(&data_type),
1016            data_type,
1017        })
1018    }
1019
1020    fn scalar_value_from_stat(
1021        value: Option<&ArrayRef>,
1022        data_type: &DataType,
1023    ) -> Result<ScalarValue> {
1024        let array = value
1025            .cloned()
1026            .unwrap_or_else(|| new_null_array(data_type, 1));
1027        Ok(ScalarValue::try_from_array(&array, 0)?)
1028    }
1029
1030    fn stat_count_to_u32(name: &str, value: u64) -> Result<u32> {
1031        u32::try_from(value).map_err(|_| {
1032            Error::invalid_input(format!(
1033                "{} value {} exceeds the supported UInt32 range",
1034                name, value
1035            ))
1036        })
1037    }
1038
1039    fn nan_scalar(data_type: &DataType) -> Option<ScalarValue> {
1040        match data_type {
1041            DataType::Float16 => Some(ScalarValue::Float16(Some(half::f16::NAN))),
1042            DataType::Float32 => Some(ScalarValue::Float32(Some(f32::NAN))),
1043            DataType::Float64 => Some(ScalarValue::Float64(Some(f64::NAN))),
1044            _ => None,
1045        }
1046    }
1047
1048    fn max_value_from_stats(
1049        value: Option<&ArrayRef>,
1050        data_type: &DataType,
1051        nan_count: u32,
1052    ) -> Result<ScalarValue> {
1053        if nan_count > 0
1054            && let Some(nan) = Self::nan_scalar(data_type)
1055        {
1056            // DataFusion's max accumulator surfaced NaN as the zone max.  Keep
1057            // that stored zonemap shape while using arrow_stats so existing
1058            // range/equality pruning remains conservative around NaN.
1059            return Ok(nan);
1060        }
1061        Self::scalar_value_from_stat(value, data_type)
1062    }
1063}
1064
1065impl ZoneProcessor for ZoneMapProcessor {
1066    type ZoneStatistics = ZoneMapStatistics;
1067
1068    fn process_chunk(&mut self, array: &ArrayRef) -> Result<()> {
1069        self.statistics.update(array)?;
1070        Ok(())
1071    }
1072
1073    fn finish_zone(&mut self, bound: ZoneBound) -> Result<Self::ZoneStatistics> {
1074        let statistics = self.statistics.statistics();
1075        let nan_count = Self::stat_count_to_u32("nan_count", statistics.nan_count.unwrap_or(0))?;
1076        Ok(ZoneMapStatistics {
1077            min: Self::scalar_value_from_stat(
1078                statistics.min.as_ref().map(|scalar| scalar.as_array()),
1079                &self.data_type,
1080            )?,
1081            max: Self::max_value_from_stats(
1082                statistics.max.as_ref().map(|scalar| scalar.as_array()),
1083                &self.data_type,
1084                nan_count,
1085            )?,
1086            null_count: Self::stat_count_to_u32("null_count", statistics.null_count)?,
1087            nan_count,
1088            bound,
1089        })
1090    }
1091
1092    fn reset(&mut self) -> Result<()> {
1093        self.statistics.reset();
1094        Ok(())
1095    }
1096}
1097
1098fn make_zone_map_index_details(rows_per_zone: u64, use_seeds: bool) -> prost_types::Any {
1099    prost_types::Any::from_msg(&pbold::ZoneMapIndexDetails {
1100        rows_per_zone: Some(rows_per_zone),
1101        use_seeds: Some(use_seeds),
1102    })
1103    .unwrap()
1104}
1105
1106/// Returns true when seed-based incremental updates should be enabled by
1107/// default for the given column type.
1108///
1109/// Seeds pay off for variable-length types (strings, binary) — which can be
1110/// arbitrarily wide — and fixed-width types wider than 8 bytes (e.g.
1111/// Decimal128, FixedSizeBinary tensors). Fixed-width types ≤ 8 bytes (Int64,
1112/// Float64, …) scan fast enough that the seed overhead is not worth it.
1113fn default_use_seeds(data_type: &DataType) -> bool {
1114    match data_type {
1115        // Variable-length: width is unbounded, skipping scans is always valuable.
1116        DataType::Utf8
1117        | DataType::LargeUtf8
1118        | DataType::Utf8View
1119        | DataType::Binary
1120        | DataType::LargeBinary
1121        | DataType::BinaryView => true,
1122        // Fixed-width types wider than 8 bytes.
1123        DataType::Decimal128(_, _) | DataType::Decimal256(_, _) => true,
1124        DataType::FixedSizeBinary(n) => *n > 8,
1125        _ => false,
1126    }
1127}
1128
1129#[derive(Debug, Default)]
1130pub struct ZoneMapIndexPlugin;
1131
1132impl ZoneMapIndexPlugin {
1133    async fn train_zonemap_index(
1134        batches_source: SendableRecordBatchStream,
1135        index_store: &dyn IndexStore,
1136        options: Option<ZoneMapIndexBuilderParams>,
1137    ) -> Result<Vec<IndexFile>> {
1138        let value_type = batches_source.schema().field(0).data_type().clone();
1139
1140        let mut builder = ZoneMapIndexBuilder::try_new(options.unwrap_or_default(), value_type)?;
1141
1142        builder.train(batches_source).await?;
1143
1144        builder.write_index(index_store).await
1145    }
1146}
1147
1148pub struct ZoneMapIndexTrainingRequest {
1149    pub params: ZoneMapIndexBuilderParams,
1150    pub criteria: TrainingCriteria,
1151}
1152
1153impl ZoneMapIndexTrainingRequest {
1154    pub fn new(params: ZoneMapIndexBuilderParams) -> Self {
1155        Self {
1156            params,
1157            criteria: TrainingCriteria::new(TrainingOrdering::Addresses).with_row_addr(),
1158        }
1159    }
1160}
1161
1162impl TrainingRequest for ZoneMapIndexTrainingRequest {
1163    fn as_any(&self) -> &dyn std::any::Any {
1164        self
1165    }
1166    fn criteria(&self) -> &TrainingCriteria {
1167        &self.criteria
1168    }
1169}
1170
1171#[async_trait]
1172impl BasicTrainer for ZoneMapIndexPlugin {
1173    fn new_training_request(
1174        &self,
1175        params: &str,
1176        field: &Field,
1177    ) -> Result<Box<dyn TrainingRequest>> {
1178        if field.data_type().is_nested() {
1179            return Err(Error::invalid_input_source(
1180                "A zone map index can only be created on a non-nested field.".into(),
1181            ));
1182        }
1183
1184        let mut params = serde_json::from_str::<ZoneMapIndexBuilderParams>(params)?;
1185        // Resolve None → type-based default so train_index always sees Some(bool).
1186        if params.use_seeds.is_none() {
1187            params.use_seeds = Some(default_use_seeds(field.data_type()));
1188        }
1189
1190        Ok(Box::new(ZoneMapIndexTrainingRequest::new(params)))
1191    }
1192
1193    async fn train_index(
1194        &self,
1195        data: SendableRecordBatchStream,
1196        index_store: &dyn IndexStore,
1197        request: Box<dyn TrainingRequest>,
1198        _fragment_ids: Option<Vec<u32>>,
1199        _progress: Arc<dyn crate::progress::IndexBuildProgress>,
1200    ) -> Result<CreatedIndex> {
1201        let request = (request as Box<dyn std::any::Any>)
1202            .downcast::<ZoneMapIndexTrainingRequest>()
1203            .map_err(|_| {
1204                Error::invalid_input_source(
1205                    "must provide training request created by new_training_request".into(),
1206                )
1207            })?;
1208        let rows_per_zone = request.params.rows_per_zone;
1209        let use_seeds = request.params.use_seeds.unwrap_or(false);
1210        let files = Self::train_zonemap_index(data, index_store, Some(request.params)).await?;
1211        Ok(CreatedIndex {
1212            index_details: make_zone_map_index_details(rows_per_zone, use_seeds),
1213            index_version: ZONEMAP_INDEX_VERSION,
1214            files,
1215        })
1216    }
1217}
1218
1219#[async_trait]
1220impl ScalarIndexPlugin for ZoneMapIndexPlugin {
1221    fn basic_trainer(&self) -> Option<&dyn BasicTrainer> {
1222        Some(self)
1223    }
1224
1225    fn name(&self) -> &str {
1226        "ZoneMap"
1227    }
1228
1229    fn provides_exact_answer(&self) -> bool {
1230        false
1231    }
1232
1233    fn version(&self) -> u32 {
1234        ZONEMAP_INDEX_VERSION
1235    }
1236
1237    fn new_query_parser(
1238        &self,
1239        index_name: String,
1240        _index_details: &prost_types::Any,
1241    ) -> Option<Box<dyn ScalarQueryParser>> {
1242        Some(Box::new(SargableQueryParser::new(
1243            index_name,
1244            self.name().to_string(),
1245            true,
1246        )))
1247    }
1248
1249    async fn load_index(
1250        &self,
1251        index_store: Arc<dyn IndexStore>,
1252        index_details: &prost_types::Any,
1253        frag_reuse_index: Option<Arc<dyn RowIdRemapper>>,
1254        cache: &LanceCache,
1255    ) -> Result<Arc<dyn ScalarIndex>> {
1256        let use_seeds = index_details
1257            .to_msg::<pbold::ZoneMapIndexDetails>()
1258            .ok()
1259            .and_then(|d| d.use_seeds)
1260            .unwrap_or(false);
1261        Ok(
1262            ZoneMapIndex::load(index_store, frag_reuse_index, cache, use_seeds).await?
1263                as Arc<dyn ScalarIndex>,
1264        )
1265    }
1266
1267    fn might_use_seeds(&self, index_details: &prost_types::Any) -> bool {
1268        index_details
1269            .to_msg::<pbold::ZoneMapIndexDetails>()
1270            .ok()
1271            .and_then(|d| d.use_seeds)
1272            .unwrap_or(false)
1273    }
1274
1275    async fn create_seed_writer(
1276        &self,
1277        field_path: &str,
1278        data_type: &DataType,
1279        index_details: &prost_types::Any,
1280    ) -> Result<Option<Box<dyn crate::scalar::seed::IndexSeedWriter>>> {
1281        if data_type.is_nested() {
1282            return Ok(None);
1283        }
1284        let details = index_details.to_msg::<pbold::ZoneMapIndexDetails>().ok();
1285        let Some(rows_per_zone) = details.as_ref().and_then(|d| d.rows_per_zone) else {
1286            return Ok(None);
1287        };
1288        if !details.as_ref().and_then(|d| d.use_seeds).unwrap_or(false) {
1289            return Ok(None);
1290        }
1291        Ok(Some(Box::new(ZoneMapSeedWriter::new(
1292            field_path,
1293            rows_per_zone,
1294            data_type.clone(),
1295        )?)))
1296    }
1297
1298    async fn update_from_seeds(
1299        &self,
1300        seeds: Vec<crate::scalar::seed::FragmentSeed>,
1301        reference_index: Arc<dyn ScalarIndex>,
1302        index_details: &prost_types::Any,
1303        dest_store: &dyn IndexStore,
1304    ) -> Result<Option<CreatedIndex>> {
1305        let Some(rows_per_zone) = index_details
1306            .to_msg::<pbold::ZoneMapIndexDetails>()
1307            .ok()
1308            .and_then(|d| d.rows_per_zone)
1309        else {
1310            return Ok(None);
1311        };
1312
1313        // Validate each seed was written with the same rows_per_zone.
1314        for seed in &seeds {
1315            let rpz_in_seed = seed
1316                .metadata_value
1317                .split_once(':')
1318                .and_then(|(_, rpz)| rpz.parse::<u64>().ok());
1319            if rpz_in_seed != Some(rows_per_zone) {
1320                return Ok(None);
1321            }
1322        }
1323
1324        let Some(zone_map) = reference_index.as_any().downcast_ref::<ZoneMapIndex>() else {
1325            return Ok(None);
1326        };
1327        zone_map.try_update_with_seeds(&seeds, dest_store).await
1328    }
1329}
1330
1331/// A seed writer that observes column values during data file writes and
1332/// accumulates zone map statistics for later harvest during index updates.
1333///
1334/// Zone statistics are serialized as Arrow IPC bytes and embedded in the data
1335/// file footer as a global buffer, keyed by `"lance.seed.<column_name>"`.
1336#[derive(Debug)]
1337pub struct ZoneMapSeedWriter {
1338    column_name: String,
1339    rows_per_zone: u64,
1340    data_type: DataType,
1341    completed_zones: Vec<ZoneMapStatistics>,
1342    processor: ZoneMapProcessor,
1343    rows_in_current_zone: u64,
1344    next_zone_start: u64,
1345}
1346
1347impl ZoneMapSeedWriter {
1348    /// Create a new `ZoneMapSeedWriter` for the given column.
1349    pub fn new(
1350        column_name: impl Into<String>,
1351        rows_per_zone: u64,
1352        data_type: DataType,
1353    ) -> Result<Self> {
1354        if rows_per_zone == 0 {
1355            return Err(lance_core::Error::invalid_input(
1356                "rows_per_zone must be greater than zero",
1357            ));
1358        }
1359        let processor = ZoneMapProcessor::new(data_type.clone())?;
1360        Ok(Self {
1361            column_name: column_name.into(),
1362            rows_per_zone,
1363            data_type,
1364            completed_zones: Vec::new(),
1365            processor,
1366            rows_in_current_zone: 0,
1367            next_zone_start: 0,
1368        })
1369    }
1370
1371    fn seed_batch_from_zones(
1372        zones: &[ZoneMapStatistics],
1373        data_type: &DataType,
1374    ) -> Result<arrow_array::RecordBatch> {
1375        let mins = if zones.is_empty() {
1376            arrow_array::new_empty_array(data_type)
1377        } else {
1378            datafusion_common::ScalarValue::iter_to_array(zones.iter().map(|s| s.min.clone()))?
1379        };
1380        let maxs = if zones.is_empty() {
1381            arrow_array::new_empty_array(data_type)
1382        } else {
1383            datafusion_common::ScalarValue::iter_to_array(zones.iter().map(|s| s.max.clone()))?
1384        };
1385        let null_counts =
1386            arrow_array::UInt32Array::from_iter_values(zones.iter().map(|s| s.null_count));
1387        let nan_counts =
1388            arrow_array::UInt32Array::from_iter_values(zones.iter().map(|s| s.nan_count));
1389        let zone_lengths =
1390            arrow_array::UInt64Array::from_iter_values(zones.iter().map(|s| s.bound.length as u64));
1391
1392        let schema = Arc::new(arrow_schema::Schema::new(vec![
1393            Field::new("min", data_type.clone(), true),
1394            Field::new("max", data_type.clone(), true),
1395            Field::new("null_count", DataType::UInt32, false),
1396            Field::new("nan_count", DataType::UInt32, false),
1397            Field::new("zone_length", DataType::UInt64, false),
1398        ]));
1399
1400        let columns: Vec<ArrayRef> = vec![
1401            mins,
1402            maxs,
1403            Arc::new(null_counts) as ArrayRef,
1404            Arc::new(nan_counts) as ArrayRef,
1405            Arc::new(zone_lengths) as ArrayRef,
1406        ];
1407        Ok(arrow_array::RecordBatch::try_new(schema, columns)?)
1408    }
1409
1410    /// Deserialize zone map seed bytes (Arrow IPC) into zone statistics.
1411    ///
1412    /// Returns a list of `ZoneMapStatistics` with bounds reconstructed from
1413    /// the sequential layout (zone `i` starts at `i * rows_per_zone`).
1414    pub(crate) fn deserialize_seed(
1415        fragment_id: u64,
1416        bytes: &bytes::Bytes,
1417        rows_per_zone: u64,
1418    ) -> Result<Vec<ZoneMapStatistics>> {
1419        use arrow_ipc::reader::FileReader;
1420        use std::io::Cursor;
1421
1422        let cursor = Cursor::new(bytes.as_ref());
1423        let mut reader = FileReader::try_new(cursor, None).map_err(|e| {
1424            lance_core::Error::invalid_input(format!("failed to read zone map seed IPC: {}", e))
1425        })?;
1426
1427        let batch = match reader.next() {
1428            Some(Ok(batch)) => batch,
1429            Some(Err(e)) => {
1430                return Err(lance_core::Error::invalid_input(format!(
1431                    "failed to read zone map seed batch: {}",
1432                    e
1433                )));
1434            }
1435            None => return Ok(Vec::new()),
1436        };
1437
1438        let min_col = batch
1439            .column_by_name("min")
1440            .ok_or_else(|| lance_core::Error::invalid_input("seed batch missing 'min' column"))?;
1441        let max_col = batch
1442            .column_by_name("max")
1443            .ok_or_else(|| lance_core::Error::invalid_input("seed batch missing 'max' column"))?;
1444        let null_count_col = batch
1445            .column_by_name("null_count")
1446            .ok_or_else(|| {
1447                lance_core::Error::invalid_input("seed batch missing 'null_count' column")
1448            })?
1449            .as_any()
1450            .downcast_ref::<arrow_array::UInt32Array>()
1451            .ok_or_else(|| lance_core::Error::invalid_input("seed 'null_count' is not UInt32"))?;
1452        let nan_count_col = batch
1453            .column_by_name("nan_count")
1454            .ok_or_else(|| {
1455                lance_core::Error::invalid_input("seed batch missing 'nan_count' column")
1456            })?
1457            .as_any()
1458            .downcast_ref::<arrow_array::UInt32Array>()
1459            .ok_or_else(|| lance_core::Error::invalid_input("seed 'nan_count' is not UInt32"))?;
1460        let zone_length_col = batch
1461            .column_by_name("zone_length")
1462            .ok_or_else(|| {
1463                lance_core::Error::invalid_input("seed batch missing 'zone_length' column")
1464            })?
1465            .as_any()
1466            .downcast_ref::<arrow_array::UInt64Array>()
1467            .ok_or_else(|| lance_core::Error::invalid_input("seed 'zone_length' is not UInt64"))?;
1468
1469        let num_zones = batch.num_rows();
1470        let mut zones = Vec::with_capacity(num_zones);
1471        for i in 0..num_zones {
1472            let zone_start = i as u64 * rows_per_zone;
1473            let zone_length = zone_length_col.value(i) as usize;
1474            zones.push(ZoneMapStatistics {
1475                min: datafusion_common::ScalarValue::try_from_array(min_col, i)?,
1476                max: datafusion_common::ScalarValue::try_from_array(max_col, i)?,
1477                null_count: null_count_col.value(i),
1478                nan_count: nan_count_col.value(i),
1479                bound: ZoneBound {
1480                    fragment_id,
1481                    start: zone_start,
1482                    length: zone_length,
1483                },
1484            });
1485        }
1486        Ok(zones)
1487    }
1488}
1489
1490impl IndexSeedWriter for ZoneMapSeedWriter {
1491    fn column_name(&self) -> &str {
1492        &self.column_name
1493    }
1494
1495    fn observe_batch(&mut self, values: &ArrayRef) -> lance_core::Result<()> {
1496        let mut offset = 0usize;
1497
1498        while offset < values.len() {
1499            let remaining_in_zone = self.rows_per_zone - self.rows_in_current_zone;
1500            let chunk_len = ((values.len() as u64 - offset as u64).min(remaining_in_zone)) as usize;
1501            let chunk = values.slice(offset, chunk_len);
1502            self.processor.process_chunk(&chunk)?;
1503            self.rows_in_current_zone += chunk_len as u64;
1504            offset += chunk_len;
1505
1506            if self.rows_in_current_zone >= self.rows_per_zone {
1507                let bound = ZoneBound {
1508                    fragment_id: 0,
1509                    start: self.next_zone_start,
1510                    length: self.rows_per_zone as usize,
1511                };
1512                let stats = self.processor.finish_zone(bound)?;
1513                self.processor.reset()?;
1514                self.completed_zones.push(stats);
1515                self.next_zone_start += self.rows_per_zone;
1516                self.rows_in_current_zone = 0;
1517            }
1518        }
1519        Ok(())
1520    }
1521
1522    fn finish(&mut self) -> lance_core::Result<Option<bytes::Bytes>> {
1523        use arrow_ipc::writer::FileWriter;
1524        use std::io::Cursor;
1525
1526        // Flush partial final zone
1527        if self.rows_in_current_zone > 0 {
1528            let bound = ZoneBound {
1529                fragment_id: 0,
1530                start: self.next_zone_start,
1531                length: self.rows_in_current_zone as usize,
1532            };
1533            let stats = self.processor.finish_zone(bound)?;
1534            self.processor.reset()?;
1535            self.completed_zones.push(stats);
1536            self.next_zone_start += self.rows_in_current_zone;
1537            self.rows_in_current_zone = 0;
1538        }
1539
1540        if self.completed_zones.is_empty() {
1541            return Ok(None);
1542        }
1543
1544        let batch = Self::seed_batch_from_zones(&self.completed_zones, &self.data_type)?;
1545
1546        // Serialize to Arrow IPC
1547        let mut buf = Cursor::new(Vec::new());
1548        {
1549            let mut writer = FileWriter::try_new(&mut buf, batch.schema_ref()).map_err(|e| {
1550                lance_core::Error::invalid_input(format!("failed to create IPC writer: {}", e))
1551            })?;
1552            writer.write(&batch).map_err(|e| {
1553                lance_core::Error::invalid_input(format!("failed to write IPC batch: {}", e))
1554            })?;
1555            writer.finish().map_err(|e| {
1556                lance_core::Error::invalid_input(format!("failed to finish IPC writer: {}", e))
1557            })?;
1558        }
1559
1560        // Reset state for next fragment
1561        self.completed_zones.clear();
1562        self.next_zone_start = 0;
1563        self.processor = ZoneMapProcessor::new(self.data_type.clone())?;
1564
1565        Ok(Some(bytes::Bytes::from(buf.into_inner())))
1566    }
1567
1568    fn schema_metadata_key(&self) -> String {
1569        format!(
1570            "{}{}",
1571            crate::scalar::seed::SEED_META_KEY_PREFIX,
1572            self.column_name
1573        )
1574    }
1575
1576    fn schema_metadata_value(&self, buf_index: u32) -> String {
1577        format!("{}:{}", buf_index, self.rows_per_zone)
1578    }
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583    use crate::scalar::registry::VALUE_COLUMN_NAME;
1584    use crate::scalar::{IndexStore, zonemap::ROWS_PER_ZONE_DEFAULT};
1585    use std::sync::Arc;
1586
1587    use crate::scalar::zoned::ZoneBound;
1588    use crate::scalar::zonemap::{ZoneMapIndexPlugin, ZoneMapStatistics};
1589    use arrow::datatypes::{ArrowPrimitiveType, Float32Type, Int64Type};
1590    use arrow_array::{Array, PrimitiveArray, RecordBatch, UInt64Array, record_batch};
1591    use arrow_schema::{DataType, Field, Schema};
1592    use datafusion::execution::SendableRecordBatchStream;
1593    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
1594    use datafusion_common::ScalarValue;
1595    use futures::{StreamExt, TryStreamExt, stream};
1596    use lance_core::utils::tempfile::TempObjDir;
1597    use lance_core::{
1598        ROW_ADDR,
1599        cache::{LanceCache, WeakLanceCache},
1600    };
1601    use lance_datafusion::datagen::DatafusionDatagenExt;
1602    use lance_datagen::ArrayGeneratorExt;
1603    use lance_datagen::{BatchCount, RowCount, array};
1604    use lance_io::object_store::ObjectStore;
1605    use lance_select::RowAddrTreeMap;
1606
1607    use crate::scalar::{
1608        SargableQuery, ScalarIndex, SearchResult,
1609        lance_format::LanceIndexStore,
1610        zonemap::{
1611            ZONEMAP_FILENAME, ZONEMAP_SIZE_META_KEY, ZoneMapIndex, ZoneMapIndexBuilderParams,
1612            merge_zonemap_indices,
1613        },
1614    };
1615
1616    // Add missing imports for the tests
1617    use crate::Index; // Import Index trait to access calculate_included_frags
1618    use crate::metrics::NoOpMetricsCollector;
1619    use roaring::RoaringBitmap; // Import RoaringBitmap for the test
1620    use std::collections::Bound;
1621
1622    // Adds a _rowaddr column emulating each batch as a new fragment
1623    fn add_row_addr(stream: SendableRecordBatchStream) -> SendableRecordBatchStream {
1624        let schema = stream.schema();
1625        let schema_with_row_addr = Arc::new(Schema::new(vec![
1626            schema.field(0).clone(),
1627            Field::new(ROW_ADDR, DataType::UInt64, false),
1628        ]));
1629        let schema = schema_with_row_addr.clone();
1630        let stream = stream.enumerate().map(move |(frag_id, batch)| {
1631            let batch = batch.unwrap();
1632            let row_addr = Arc::new(UInt64Array::from_iter_values(
1633                (0..batch.num_rows() as u64).map(|off| off + ((frag_id as u64) << 32)),
1634            ));
1635            Ok(RecordBatch::try_new(
1636                schema_with_row_addr.clone(),
1637                vec![batch.column(0).clone(), row_addr],
1638            )?)
1639        });
1640        Box::pin(RecordBatchStreamAdapter::new(schema, stream))
1641    }
1642
1643    /// Build a single-column ZoneMap of primitive type `T` from `fragments`
1644    /// (one batch -> one fragment), with small zones, then load it back.
1645    async fn train_and_load<T: ArrowPrimitiveType>(
1646        fragments: Vec<Vec<Option<T::Native>>>,
1647    ) -> Arc<ZoneMapIndex>
1648    where
1649        PrimitiveArray<T>: From<Vec<Option<T::Native>>>,
1650    {
1651        let tmpdir = TempObjDir::default();
1652        let test_store = Arc::new(LanceIndexStore::new(
1653            Arc::new(ObjectStore::local()),
1654            tmpdir.clone(),
1655            Arc::new(LanceCache::no_cache()),
1656        ));
1657        let schema = Arc::new(Schema::new(vec![Field::new(
1658            VALUE_COLUMN_NAME,
1659            T::DATA_TYPE,
1660            true,
1661        )]));
1662        let batches: Vec<RecordBatch> = fragments
1663            .into_iter()
1664            .map(|vals| {
1665                RecordBatch::try_new(
1666                    schema.clone(),
1667                    vec![Arc::new(PrimitiveArray::<T>::from(vals))],
1668                )
1669                .unwrap()
1670            })
1671            .collect();
1672        let stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
1673            schema.clone(),
1674            stream::iter(batches.into_iter().map(Ok)),
1675        ));
1676        let stream = add_row_addr(stream);
1677
1678        ZoneMapIndexPlugin::train_zonemap_index(
1679            stream,
1680            test_store.as_ref(),
1681            Some(ZoneMapIndexBuilderParams::new(2)),
1682        )
1683        .await
1684        .unwrap();
1685
1686        ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
1687            .await
1688            .expect("Failed to load ZoneMapIndex")
1689    }
1690
1691    #[tokio::test]
1692    async fn test_value_range_spans_fragments() {
1693        // Two fragments, multiple zones each; global min/max straddle both.
1694        let index = train_and_load::<Int64Type>(vec![
1695            vec![Some(10), Some(50), Some(30)],
1696            vec![Some(5), Some(99), Some(42)],
1697        ])
1698        .await;
1699        assert_eq!(
1700            index.value_range(),
1701            Some((ScalarValue::Int64(Some(5)), ScalarValue::Int64(Some(99))))
1702        );
1703    }
1704
1705    #[tokio::test]
1706    async fn test_value_range_all_null_is_none() {
1707        let index = train_and_load::<Int64Type>(vec![vec![None, None, None]]).await;
1708        assert_eq!(index.value_range(), None);
1709    }
1710
1711    #[tokio::test]
1712    async fn test_value_range_nan_max_is_none() {
1713        // Zones of size 2: [1.0, 2.0] then [100.0, NaN]. The NaN-bearing zone hides
1714        // its finite max (100.0), so the only sound answer is None.
1715        let index = train_and_load::<Float32Type>(vec![vec![
1716            Some(1.0),
1717            Some(2.0),
1718            Some(100.0),
1719            Some(f32::NAN),
1720        ]])
1721        .await;
1722        assert_eq!(index.value_range(), None);
1723    }
1724
1725    #[tokio::test]
1726    async fn test_value_range_over_folds_segments() {
1727        // Two disjoint segments of one logical index; the global range straddles
1728        // both (min and max from `b`), proving the fold spans segments.
1729        let a = train_and_load::<Int64Type>(vec![vec![Some(5), Some(9)]]).await;
1730        let b = train_and_load::<Int64Type>(vec![vec![Some(1), Some(20)]]).await;
1731        assert_eq!(
1732            ZoneMapIndex::value_range_over([a.as_ref(), b.as_ref()]),
1733            Some((ScalarValue::Int64(Some(1)), ScalarValue::Int64(Some(20))))
1734        );
1735    }
1736
1737    #[tokio::test]
1738    async fn test_value_range_over_nan_in_any_segment_is_none() {
1739        // NaN in one segment hides that segment's finite max; the cross-segment
1740        // fold must bail to None just as the single-segment path does.
1741        let a = train_and_load::<Float32Type>(vec![vec![Some(1.0), Some(2.0)]]).await;
1742        let b = train_and_load::<Float32Type>(vec![vec![Some(100.0), Some(f32::NAN)]]).await;
1743        assert_eq!(
1744            ZoneMapIndex::value_range_over([a.as_ref(), b.as_ref()]),
1745            None
1746        );
1747    }
1748
1749    #[tokio::test]
1750    async fn test_value_range_over_skips_all_null_segment() {
1751        // An all-null segment yields no finite zone; folding it with a finite
1752        // segment returns the finite segment's range (null contributes nothing).
1753        let a = train_and_load::<Int64Type>(vec![vec![None, None]]).await;
1754        let b = train_and_load::<Int64Type>(vec![vec![Some(3), Some(7)]]).await;
1755        assert_eq!(
1756            ZoneMapIndex::value_range_over([a.as_ref(), b.as_ref()]),
1757            Some((ScalarValue::Int64(Some(3)), ScalarValue::Int64(Some(7))))
1758        );
1759    }
1760
1761    #[tokio::test]
1762    async fn test_empty_zonemap_index() {
1763        let tmpdir = TempObjDir::default();
1764        let test_store = Arc::new(LanceIndexStore::new(
1765            Arc::new(ObjectStore::local()),
1766            tmpdir.clone(),
1767            Arc::new(LanceCache::no_cache()),
1768        ));
1769
1770        let data = arrow_array::Int32Array::from(Vec::<i32>::new());
1771        let row_ids = arrow_array::UInt64Array::from(Vec::<u64>::new());
1772        let schema = Arc::new(Schema::new(vec![
1773            Field::new(VALUE_COLUMN_NAME, DataType::Int32, false),
1774            Field::new(ROW_ADDR, DataType::UInt64, false),
1775        ]));
1776        let data =
1777            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
1778
1779        let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
1780            schema,
1781            stream::once(std::future::ready(Ok(data))),
1782        ));
1783
1784        ZoneMapIndexPlugin::train_zonemap_index(data_stream, test_store.as_ref(), None)
1785            .await
1786            .unwrap();
1787
1788        log::debug!("Successfully wrote the index file");
1789
1790        // Read the index file back and check its contents
1791        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
1792            .await
1793            .expect("Failed to load ZoneMapIndex");
1794        assert_eq!(index.zones.len(), 0);
1795        assert_eq!(index.data_type, DataType::Int32);
1796        assert_eq!(index.rows_per_zone, ROWS_PER_ZONE_DEFAULT);
1797
1798        // Equals query: null (should match nothing, as there are no nulls)
1799        let query = SargableQuery::Equals(ScalarValue::Int32(None));
1800        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1801        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1802    }
1803
1804    #[tokio::test]
1805    // Test that a zonemap index can be created with null values from few fragments
1806    async fn test_null_zonemap_index() {
1807        let tmpdir = TempObjDir::default();
1808        let test_store = Arc::new(LanceIndexStore::new(
1809            Arc::new(ObjectStore::local()),
1810            tmpdir.clone(),
1811            Arc::new(LanceCache::no_cache()),
1812        ));
1813
1814        let stream = lance_datagen::gen_batch()
1815            .col(
1816                VALUE_COLUMN_NAME,
1817                array::rand::<Float32Type>().with_nulls(&[true, false, false, false, false]),
1818            )
1819            .into_df_stream(RowCount::from(5000), BatchCount::from(10));
1820
1821        // Add _rowaddr column
1822        let stream = add_row_addr(stream);
1823
1824        ZoneMapIndexPlugin::train_zonemap_index(
1825            stream,
1826            test_store.as_ref(),
1827            Some(ZoneMapIndexBuilderParams::new(5000)),
1828        )
1829        .await
1830        .unwrap();
1831
1832        log::debug!("Successfully wrote the index file");
1833
1834        // Read the index file back and check its contents
1835        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
1836            .await
1837            .expect("Failed to load ZoneMapIndex");
1838        assert_eq!(index.zones.len(), 10);
1839        for (i, zone) in index.zones.iter().enumerate() {
1840            assert_eq!(zone.null_count, 1000);
1841            assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
1842            assert_eq!(zone.bound.length, 5000);
1843            assert_eq!(zone.bound.fragment_id, i as u64);
1844        }
1845
1846        // Equals query: null (should match all zones since they contain null values)
1847        let query = SargableQuery::Equals(ScalarValue::Int32(None));
1848        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1849
1850        // Create expected RowAddrTreeMap with all zones since they contain null values
1851        let mut expected = RowAddrTreeMap::new();
1852        for fragment_id in 0..10 {
1853            let start = (fragment_id as u64) << 32;
1854            let end = start + 5000;
1855            expected.insert_range(start..end);
1856        }
1857        assert_eq!(result, SearchResult::at_most(expected));
1858
1859        // Test update - add new data with Float32 values (matching the original data type)
1860        let new_data =
1861            arrow_array::Float32Array::from_iter_values((0..5000).map(|i| i as f32 / 1000.0));
1862        // Create row addresses for fragment 10 (next fragment after 0-9)
1863        let new_row_addr =
1864            UInt64Array::from_iter_values((0..5000).map(|i| (10u64 << 32) | (i as u64)));
1865        let new_schema = Arc::new(Schema::new(vec![
1866            Field::new(VALUE_COLUMN_NAME, DataType::Float32, false), // Match original schema
1867            Field::new(ROW_ADDR, DataType::UInt64, false), // Use _rowaddr as expected by the builder
1868        ]));
1869        let new_data_batch = RecordBatch::try_new(
1870            new_schema.clone(),
1871            vec![Arc::new(new_data), Arc::new(new_row_addr)],
1872        )
1873        .unwrap();
1874        let new_data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
1875            new_schema,
1876            stream::once(std::future::ready(Ok(new_data_batch))),
1877        ));
1878
1879        // Directly pass the stream with proper row addresses instead of using MockTrainingSource
1880        // which would regenerate row addresses starting from 0
1881        index
1882            .update(new_data_stream, test_store.as_ref(), None)
1883            .await
1884            .unwrap();
1885
1886        // Verify the updated index has more zones
1887        let updated_index =
1888            ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
1889                .await
1890                .expect("Failed to load updated ZoneMapIndex");
1891
1892        // Should have original 10 zones + 1 new zone (5000 rows with zone size 5000)
1893        assert_eq!(updated_index.zones.len(), 11);
1894
1895        // Verify the new zone was added
1896        let new_zone = &updated_index.zones[10]; // Last zone should be the new one
1897        assert_eq!(new_zone.bound.fragment_id, 10u64); // New fragment ID
1898        assert_eq!(new_zone.bound.length, 5000);
1899        assert_eq!(new_zone.null_count, 0); // New data has no nulls
1900        assert_eq!(new_zone.nan_count, 0); // New data has no NaN values
1901
1902        // Test search on updated index - search for null values should still work
1903        let query = SargableQuery::Equals(ScalarValue::Float32(None));
1904        let result = updated_index
1905            .search(&query, &NoOpMetricsCollector)
1906            .await
1907            .unwrap();
1908
1909        // Should match original 10 zones (with nulls) but not the new zone (no nulls)
1910        let mut expected = RowAddrTreeMap::new();
1911        for fragment_id in 0..10 {
1912            let start = (fragment_id as u64) << 32;
1913            let end = start + 5000;
1914            expected.insert_range(start..end);
1915        }
1916        assert_eq!(result, SearchResult::at_most(expected));
1917
1918        // Test search for a value that should be in the new zone
1919        let query = SargableQuery::Equals(ScalarValue::Float32(Some(2.5))); // Value 2500/1000 = 2.5
1920        let result = updated_index
1921            .search(&query, &NoOpMetricsCollector)
1922            .await
1923            .unwrap();
1924
1925        // Should match the new zone (fragment 10)
1926        let mut expected = RowAddrTreeMap::new();
1927        let start = 10u64 << 32;
1928        let end = start + 5000;
1929        expected.insert_range(start..end);
1930        assert_eq!(result, SearchResult::at_most(expected));
1931    }
1932
1933    #[tokio::test]
1934    async fn test_zonemap_null_handling_in_queries() {
1935        // Test that zonemap index correctly returns null_list for queries
1936        let tmpdir = TempObjDir::default();
1937        let store = Arc::new(LanceIndexStore::new(
1938            Arc::new(ObjectStore::local()),
1939            tmpdir.clone(),
1940            Arc::new(LanceCache::no_cache()),
1941        ));
1942
1943        // Create test data: [0, 5, null]
1944        let batch = record_batch!(
1945            (VALUE_COLUMN_NAME, Int64, [Some(0), Some(5), None]),
1946            (ROW_ADDR, UInt64, [0, 1, 2])
1947        )
1948        .unwrap();
1949        let schema = batch.schema();
1950        let stream = stream::once(async move { Ok(batch) });
1951        let stream = Box::pin(RecordBatchStreamAdapter::new(schema, stream));
1952
1953        // Train and write the zonemap index
1954        ZoneMapIndexPlugin::train_zonemap_index(stream, store.as_ref(), None)
1955            .await
1956            .unwrap();
1957
1958        let cache = LanceCache::with_capacity(1024 * 1024);
1959        let index = ZoneMapIndex::load(store.clone(), None, &cache, false)
1960            .await
1961            .unwrap();
1962
1963        // Test 1: Search for value 5 - zonemap should return at_most with all rows
1964        // Since ZoneMap returns AtMost (superset), it's correct to include nulls in the result
1965        let query = SargableQuery::Equals(ScalarValue::Int64(Some(5)));
1966        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1967
1968        match result {
1969            SearchResult::AtMost(row_ids) => {
1970                // Zonemap can't determine exact matches, so it returns all rows in the zone
1971                // This includes nulls because ZoneMap can't prove they don't match
1972                let all_rows: Vec<u64> = row_ids
1973                    .true_rows()
1974                    .row_addrs()
1975                    .unwrap()
1976                    .map(u64::from)
1977                    .collect();
1978                assert_eq!(
1979                    all_rows,
1980                    vec![0, 1, 2],
1981                    "Should return all rows (including nulls) since ZoneMap is inexact"
1982                );
1983
1984                // For AtMost results, nulls are included in the superset
1985                // Downstream processing will handle null filtering
1986            }
1987            _ => panic!("Expected AtMost search result from zonemap"),
1988        }
1989
1990        // Test 2: Range query - should also return all rows as AtMost
1991        let query = SargableQuery::Range(
1992            std::ops::Bound::Included(ScalarValue::Int64(Some(0))),
1993            std::ops::Bound::Included(ScalarValue::Int64(Some(3))),
1994        );
1995        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1996
1997        match result {
1998            SearchResult::AtMost(row_ids) => {
1999                // Again, ZoneMap returns superset including nulls
2000                let all_rows: Vec<u64> = row_ids
2001                    .true_rows()
2002                    .row_addrs()
2003                    .unwrap()
2004                    .map(u64::from)
2005                    .collect();
2006                assert_eq!(
2007                    all_rows,
2008                    vec![0, 1, 2],
2009                    "Should return all rows in zone as possible matches"
2010                );
2011            }
2012            _ => panic!("Expected AtMost search result from zonemap"),
2013        }
2014    }
2015
2016    #[tokio::test]
2017    async fn test_nan_zonemap_index() {
2018        let tmpdir = TempObjDir::default();
2019        let test_store = Arc::new(LanceIndexStore::new(
2020            Arc::new(ObjectStore::local()),
2021            tmpdir.clone(),
2022            Arc::new(LanceCache::no_cache()),
2023        ));
2024
2025        // Create deterministic data with NaN values
2026        // Pattern: [1.0, 2.0, NaN, 3.0, 4.0, 5.0, NaN, 6.0, 7.0, 8.0, ...]
2027        let mut values = Vec::new();
2028        for i in 0..500 {
2029            if i % 5 == 2 {
2030                values.push(f32::NAN);
2031            } else {
2032                // Other values are sequential numbers
2033                values.push(i as f32);
2034            }
2035        }
2036
2037        let float_data = arrow_array::Float32Array::from(values);
2038        let row_ids = UInt64Array::from_iter_values((0..float_data.len()).map(|i| i as u64));
2039        let schema = Arc::new(Schema::new(vec![
2040            Field::new(VALUE_COLUMN_NAME, DataType::Float32, true),
2041            Field::new(ROW_ADDR, DataType::UInt64, false),
2042        ]));
2043        let data = RecordBatch::try_new(
2044            schema.clone(),
2045            vec![Arc::new(float_data.clone()), Arc::new(row_ids)],
2046        )
2047        .unwrap();
2048        let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2049            schema,
2050            stream::once(std::future::ready(Ok(data))),
2051        ));
2052
2053        ZoneMapIndexPlugin::train_zonemap_index(
2054            data_stream,
2055            test_store.as_ref(),
2056            Some(ZoneMapIndexBuilderParams::new(100)),
2057        )
2058        .await
2059        .unwrap();
2060
2061        // Load the index
2062        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
2063            .await
2064            .expect("Failed to load ZoneMapIndex");
2065
2066        // Should have 5 zones since we have 500 rows and zone size is 100
2067        assert_eq!(index.zones.len(), 5);
2068
2069        // Check that each zone has the expected NaN count
2070        // Each zone has 100 values, and every 5th value (indices 2, 7, 12, ...) is NaN
2071        // So each zone should have 20 NaN values (100/5 = 20)
2072        for (i, zone) in index.zones.iter().enumerate() {
2073            assert_eq!(zone.nan_count, 20, "Zone {} should have 20 NaN values", i);
2074            assert_eq!(
2075                zone.bound.length, 100,
2076                "Zone {} should have zone_length 100",
2077                i
2078            );
2079            assert_eq!(
2080                zone.bound.fragment_id, 0u64,
2081                "Zone {} should have fragment_id 0",
2082                i
2083            );
2084        }
2085
2086        let zone = &index.zones[0];
2087        assert!(matches!(
2088            zone.max,
2089            ScalarValue::Float32(Some(value)) if value.is_nan()
2090        ));
2091        let finite_target = ScalarValue::Float32(Some(1000.0));
2092        assert!(
2093            finite_target >= zone.min && finite_target <= zone.max,
2094            "ScalarValue total ordering keeps finite values below NaN max"
2095        );
2096
2097        // Test search for NaN values using Equals with NaN
2098        let query = SargableQuery::Equals(ScalarValue::Float32(Some(f32::NAN)));
2099        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2100
2101        // Should match all zones since they all contain NaN values
2102        let mut expected = RowAddrTreeMap::new();
2103        expected.insert_range(0..500); // All rows since NaN is in every zone
2104        assert_eq!(result, SearchResult::at_most(expected));
2105
2106        // Test search for a specific finite value that exists in the data
2107        let query = SargableQuery::Equals(ScalarValue::Float32(Some(5.0)));
2108        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2109
2110        // Should match only the first zone since 5.0 only exists in rows 0-99
2111        let mut expected = RowAddrTreeMap::new();
2112        expected.insert_range(0..100);
2113        assert_eq!(result, SearchResult::at_most(expected));
2114
2115        // Test search for a value that doesn't exist
2116        let query = SargableQuery::Equals(ScalarValue::Float32(Some(1000.0)));
2117        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2118
2119        // Since zones contain NaN values, their max will be NaN, so they will be included
2120        // as potential matches for any finite target (false positive, but acceptable for zone maps)
2121        let mut expected = RowAddrTreeMap::new();
2122        expected.insert_range(0..500);
2123        assert_eq!(result, SearchResult::at_most(expected));
2124
2125        // Test range query that should include finite values
2126        let query = SargableQuery::Range(
2127            Bound::Included(ScalarValue::Float32(Some(0.0))),
2128            Bound::Included(ScalarValue::Float32(Some(250.0))),
2129        );
2130        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2131
2132        // Should match the first three zones since they contain values in the range [0, 250]
2133        let mut expected = RowAddrTreeMap::new();
2134        expected.insert_range(0..300);
2135        assert_eq!(result, SearchResult::at_most(expected));
2136
2137        // Test IsIn query with NaN and finite values
2138        let query = SargableQuery::IsIn(vec![
2139            ScalarValue::Float32(Some(f32::NAN)),
2140            ScalarValue::Float32(Some(5.0)),
2141            ScalarValue::Float32(Some(150.0)), // This value exists in the second zone
2142        ]);
2143        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2144
2145        // Should match all zones since they all contain NaN values
2146        let mut expected = RowAddrTreeMap::new();
2147        expected.insert_range(0..500);
2148        assert_eq!(result, SearchResult::at_most(expected));
2149
2150        // Test range query that excludes all values
2151        let query = SargableQuery::Range(
2152            Bound::Included(ScalarValue::Float32(Some(1000.0))),
2153            Bound::Included(ScalarValue::Float32(Some(2000.0))),
2154        );
2155        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2156
2157        // Since zones contain NaN values, their max will be NaN, so they will be included
2158        // as potential matches for any range query (false positive, but acceptable for zone maps)
2159        let mut expected = RowAddrTreeMap::new();
2160        expected.insert_range(0..500);
2161        assert_eq!(result, SearchResult::at_most(expected));
2162
2163        // Test IsNull query (should match nothing since there are no null values)
2164        let query = SargableQuery::IsNull();
2165        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2166        assert_eq!(result, SearchResult::exact(RowAddrTreeMap::new()));
2167
2168        // Test range queries with NaN bounds
2169        // Range with NaN as start bound (included)
2170        let query = SargableQuery::Range(
2171            Bound::Included(ScalarValue::Float32(Some(f32::NAN))),
2172            Bound::Unbounded,
2173        );
2174        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2175        // Should match all zones since they all contain NaN values
2176        let mut expected = RowAddrTreeMap::new();
2177        expected.insert_range(0..500);
2178        assert_eq!(result, SearchResult::at_most(expected));
2179
2180        // Range with NaN as end bound (included)
2181        let query = SargableQuery::Range(
2182            Bound::Unbounded,
2183            Bound::Included(ScalarValue::Float32(Some(f32::NAN))),
2184        );
2185        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2186        // Should match all zones since they all contain NaN values
2187        let mut expected = RowAddrTreeMap::new();
2188        expected.insert_range(0..500);
2189        assert_eq!(result, SearchResult::at_most(expected));
2190
2191        // Range with NaN as end bound (excluded)
2192        let query = SargableQuery::Range(
2193            Bound::Unbounded,
2194            Bound::Excluded(ScalarValue::Float32(Some(f32::NAN))),
2195        );
2196        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2197        // Should match all zones since everything is less than NaN
2198        let mut expected = RowAddrTreeMap::new();
2199        expected.insert_range(0..500);
2200        assert_eq!(result, SearchResult::at_most(expected));
2201
2202        // Range with NaN as start bound (excluded)
2203        let query = SargableQuery::Range(
2204            Bound::Excluded(ScalarValue::Float32(Some(f32::NAN))),
2205            Bound::Unbounded,
2206        );
2207        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2208        // Should match nothing since nothing is greater than NaN
2209        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2210
2211        // Test IsIn query with mixed float types (Float16, Float32, Float64)
2212        let query = SargableQuery::IsIn(vec![
2213            ScalarValue::Float16(Some(half::f16::NAN)),
2214            ScalarValue::Float32(Some(f32::NAN)),
2215            ScalarValue::Float64(Some(f64::NAN)),
2216            ScalarValue::Float32(Some(5.0)),
2217        ]);
2218        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2219        // Should match all zones since they all contain NaN values
2220        let mut expected = RowAddrTreeMap::new();
2221        expected.insert_range(0..500);
2222        assert_eq!(result, SearchResult::at_most(expected));
2223    }
2224
2225    #[tokio::test]
2226    // Test data that belongs to the same fragment but coming from different batches
2227    async fn test_basic_zonemap_index() {
2228        let tmpdir = TempObjDir::default();
2229        let test_store = Arc::new(LanceIndexStore::new(
2230            Arc::new(ObjectStore::local()),
2231            tmpdir.clone(),
2232            Arc::new(LanceCache::no_cache()),
2233        ));
2234
2235        let data = arrow_array::Int32Array::from_iter_values(0..=100);
2236        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
2237        let schema = Arc::new(Schema::new(vec![
2238            Field::new(VALUE_COLUMN_NAME, DataType::Int32, false),
2239            Field::new(ROW_ADDR, DataType::UInt64, false),
2240        ]));
2241        let data =
2242            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
2243        let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2244            schema,
2245            stream::once(std::future::ready(Ok(data))),
2246        ));
2247
2248        ZoneMapIndexPlugin::train_zonemap_index(
2249            data_stream,
2250            test_store.as_ref(),
2251            Some(ZoneMapIndexBuilderParams::new(100)),
2252        )
2253        .await
2254        .unwrap();
2255
2256        log::debug!("Successfully wrote the index file");
2257
2258        // Read the raw index file back and check its contents
2259        let index_file = test_store.open_index_file(ZONEMAP_FILENAME).await.unwrap();
2260        // Print the metadata from the index_file
2261        let metadata = index_file.schema().metadata.clone();
2262        let record_batch = index_file
2263            .read_record_batch(0, index_file.num_rows() as u64)
2264            .await
2265            .unwrap();
2266        assert_eq!(record_batch.num_rows(), 2);
2267        assert_eq!(
2268            record_batch
2269                .column(0)
2270                .as_any()
2271                .downcast_ref::<arrow_array::Int32Array>()
2272                .unwrap()
2273                .values(),
2274            &[0, 100]
2275        );
2276        assert_eq!(
2277            record_batch
2278                .column(1)
2279                .as_any()
2280                .downcast_ref::<arrow_array::Int32Array>()
2281                .unwrap()
2282                .values(),
2283            &[99, 100]
2284        );
2285        assert_eq!(
2286            record_batch
2287                .column(2)
2288                .as_any()
2289                .downcast_ref::<arrow_array::UInt32Array>()
2290                .unwrap()
2291                .values(),
2292            &[0, 0]
2293        );
2294        assert_eq!(metadata.get(ZONEMAP_SIZE_META_KEY).unwrap(), "100");
2295
2296        // Read the index file back and check its contents
2297        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
2298            .await
2299            .expect("Failed to load ZoneMapIndex");
2300        assert_eq!(index.zones.len(), 2);
2301        assert_eq!(
2302            index.zones,
2303            vec![
2304                ZoneMapStatistics {
2305                    min: ScalarValue::Int32(Some(0)),
2306                    max: ScalarValue::Int32(Some(99)),
2307                    null_count: 0,
2308                    nan_count: 0,
2309                    bound: ZoneBound {
2310                        fragment_id: 0,
2311                        start: 0,
2312                        length: 100,
2313                    },
2314                },
2315                ZoneMapStatistics {
2316                    min: ScalarValue::Int32(Some(100)),
2317                    max: ScalarValue::Int32(Some(100)),
2318                    null_count: 0,
2319                    nan_count: 0,
2320                    bound: ZoneBound {
2321                        fragment_id: 0,
2322                        start: 100,
2323                        length: 1,
2324                    },
2325                }
2326            ]
2327        );
2328        // Verify nan_count is 0 for all zones (no NaN values in integer data)
2329        for (i, zone) in index.zones.iter().enumerate() {
2330            assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
2331        }
2332
2333        assert_eq!(index.data_type, DataType::Int32);
2334        assert_eq!(index.rows_per_zone, 100);
2335        assert_eq!(
2336            index.calculate_included_frags().await.unwrap(),
2337            RoaringBitmap::from_iter(0..1)
2338        );
2339
2340        // Test search functionality
2341
2342        // 1. Range query: (50, +inf)
2343        let query = SargableQuery::Range(
2344            Bound::Excluded(ScalarValue::Int32(Some(50))),
2345            Bound::Unbounded,
2346        );
2347        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2348        assert_eq!(result, SearchResult::at_most(0..=100));
2349
2350        // 2. Range query: [0, 50]
2351        let query = SargableQuery::Range(
2352            Bound::Included(ScalarValue::Int32(Some(0))),
2353            Bound::Included(ScalarValue::Int32(Some(50))),
2354        );
2355        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2356        assert_eq!(result, SearchResult::at_most(0..=99));
2357
2358        // 3. Range query: [101, 200] (should only match the second zone, which is row 100)
2359        let query = SargableQuery::Range(
2360            Bound::Included(ScalarValue::Int32(Some(101))),
2361            Bound::Included(ScalarValue::Int32(Some(200))),
2362        );
2363        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2364        // Only row 100 is in the second zone, but its value is 100, so this should be empty
2365        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2366
2367        // 4. Range query: [100, 100] (should match only the last row)
2368        let query = SargableQuery::Range(
2369            Bound::Included(ScalarValue::Int32(Some(100))),
2370            Bound::Included(ScalarValue::Int32(Some(100))),
2371        );
2372        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2373        assert_eq!(result, SearchResult::at_most(100..=100));
2374
2375        // 5. Equals query: 0 (should match first row)
2376        let query = SargableQuery::Equals(ScalarValue::Int32(Some(0)));
2377        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2378        assert_eq!(result, SearchResult::at_most(0..=99));
2379
2380        // 6. Equals query: 100 (should match only last row)
2381        let query = SargableQuery::Equals(ScalarValue::Int32(Some(100)));
2382        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2383        assert_eq!(result, SearchResult::at_most(100..=100));
2384
2385        // 7. Equals query: 101 (should match nothing)
2386        let query = SargableQuery::Equals(ScalarValue::Int32(Some(101)));
2387        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2388        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2389
2390        // 8. IsNull query (no nulls in data, should match nothing)
2391        let query = SargableQuery::IsNull();
2392        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2393        assert_eq!(result, SearchResult::exact(RowAddrTreeMap::new()));
2394        // 9. IsIn query: [0, 100, 101, 50]
2395        let query = SargableQuery::IsIn(vec![
2396            ScalarValue::Int32(Some(0)),
2397            ScalarValue::Int32(Some(100)),
2398            ScalarValue::Int32(Some(101)),
2399            ScalarValue::Int32(Some(50)),
2400        ]);
2401        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2402        // 0 and 50 are in the first zone, 100 in the second, 101 is not present
2403        assert_eq!(result, SearchResult::at_most(0..=100));
2404
2405        // 10. IsIn query: [101, 102] (should match nothing)
2406        let query = SargableQuery::IsIn(vec![
2407            ScalarValue::Int32(Some(101)),
2408            ScalarValue::Int32(Some(102)),
2409        ]);
2410        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2411        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2412
2413        // 11. IsIn query: [null] (should match nothing, as there are no nulls)
2414        let query = SargableQuery::IsIn(vec![ScalarValue::Int32(None)]);
2415        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2416        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2417
2418        // 12. Equals query: null (should match nothing, as there are no nulls)
2419        let query = SargableQuery::Equals(ScalarValue::Int32(None));
2420        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2421        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2422    }
2423
2424    #[tokio::test]
2425    // Test zonemap with same fragment from multiple batches
2426    async fn test_complex_zonemap_index() {
2427        let tmpdir = TempObjDir::default();
2428        let test_store = Arc::new(LanceIndexStore::new(
2429            Arc::new(ObjectStore::local()),
2430            tmpdir.clone(),
2431            Arc::new(LanceCache::no_cache()),
2432        ));
2433
2434        // Create data that will produce the expected zonemap zones
2435        let data =
2436            arrow_array::Int64Array::from_iter_values(0..(ROWS_PER_ZONE_DEFAULT * 2 + 42) as i64);
2437        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
2438        let schema = Arc::new(Schema::new(vec![
2439            Field::new(VALUE_COLUMN_NAME, DataType::Int64, false),
2440            Field::new(ROW_ADDR, DataType::UInt64, false),
2441        ]));
2442        let data =
2443            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
2444        let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2445            schema,
2446            stream::once(std::future::ready(Ok(data))),
2447        ));
2448
2449        ZoneMapIndexPlugin::train_zonemap_index(
2450            data_stream,
2451            test_store.as_ref(),
2452            Some(ZoneMapIndexBuilderParams::default()),
2453        )
2454        .await
2455        .unwrap();
2456
2457        log::debug!("Successfully wrote the index file");
2458
2459        // Read the index file back and check its contents
2460        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
2461            .await
2462            .expect("Failed to load ZoneMapIndex");
2463        assert_eq!(index.zones.len(), 3);
2464        assert_eq!(
2465            index.zones,
2466            vec![
2467                ZoneMapStatistics {
2468                    min: ScalarValue::Int64(Some(0)),
2469                    max: ScalarValue::Int64(Some(8191)),
2470                    null_count: 0,
2471                    nan_count: 0,
2472                    bound: ZoneBound {
2473                        fragment_id: 0,
2474                        start: 0,
2475                        length: 8192,
2476                    },
2477                },
2478                ZoneMapStatistics {
2479                    min: ScalarValue::Int64(Some(8192)),
2480                    max: ScalarValue::Int64(Some(16383)),
2481                    null_count: 0,
2482                    nan_count: 0,
2483                    bound: ZoneBound {
2484                        fragment_id: 0,
2485                        start: 8192,
2486                        length: 8192,
2487                    },
2488                },
2489                ZoneMapStatistics {
2490                    min: ScalarValue::Int64(Some(16384)),
2491                    max: ScalarValue::Int64(Some(16425)),
2492                    null_count: 0,
2493                    nan_count: 0,
2494                    bound: ZoneBound {
2495                        fragment_id: 0,
2496                        start: 16384,
2497                        length: 42,
2498                    },
2499                }
2500            ]
2501        );
2502        // Verify nan_count is 0 for all zones (no NaN values in integer data)
2503        for (i, zone) in index.zones.iter().enumerate() {
2504            assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
2505        }
2506
2507        assert_eq!(index.data_type, DataType::Int64);
2508        assert_eq!(index.rows_per_zone, ROWS_PER_ZONE_DEFAULT);
2509        assert_eq!(
2510            index.calculate_included_frags().await.unwrap(),
2511            RoaringBitmap::from_iter(0..1)
2512        );
2513
2514        // TODO: Test search functionality
2515        // Test search functionality
2516
2517        // Search for a value in the first zone
2518        let query = SargableQuery::Equals(ScalarValue::Int64(Some(1000)));
2519        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2520        // Should match row 1000 in fragment 0: row address = (0 << 32) + 1000 = 1000
2521        let mut expected = RowAddrTreeMap::new();
2522        expected.insert_range(0..=8191);
2523        assert_eq!(result, SearchResult::at_most(expected));
2524
2525        // Search for a value in the second zone
2526        let query = SargableQuery::Equals(ScalarValue::Int64(Some(9000)));
2527        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2528        // Should match row 9000 in fragment 0: row address = (0 << 32) + 9000 = 9000
2529        let mut expected = RowAddrTreeMap::new();
2530        expected.insert_range(8192..=16383);
2531        assert_eq!(result, SearchResult::at_most(expected));
2532
2533        // Search for a value not present in any zone
2534        let query = SargableQuery::Equals(ScalarValue::Int64(Some(20000)));
2535        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2536        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2537
2538        // Search for a range that spans multiple zones
2539        let query = SargableQuery::Range(
2540            Bound::Included(ScalarValue::Int64(Some(9000))),
2541            Bound::Included(ScalarValue::Int64(Some(16400))),
2542        );
2543        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2544        // Should match all rows from 8000 to 16400 (inclusive)
2545        let mut expected = RowAddrTreeMap::new();
2546        expected.insert_range(8192..=16425);
2547        assert_eq!(result, SearchResult::at_most(expected));
2548    }
2549
2550    #[tokio::test]
2551    // Test zonemap with multiple fragments from different batches
2552    async fn test_multiple_fragments_zonemap() {
2553        let tmpdir = TempObjDir::default();
2554        let test_store = Arc::new(LanceIndexStore::new(
2555            Arc::new(ObjectStore::local()),
2556            tmpdir.clone(),
2557            Arc::new(LanceCache::no_cache()),
2558        ));
2559
2560        let schema = Arc::new(Schema::new(vec![
2561            Field::new(VALUE_COLUMN_NAME, DataType::Int64, false),
2562            Field::new(ROW_ADDR, DataType::UInt64, false),
2563        ]));
2564
2565        // Create multiple fragments with data that will produce expected zones
2566        // Fragment 0: values 0-8191 (first zone)
2567        let fragment0_data =
2568            arrow_array::Int64Array::from_iter_values(0..ROWS_PER_ZONE_DEFAULT as i64);
2569        let fragment0_row_ids = UInt64Array::from_iter_values(0..ROWS_PER_ZONE_DEFAULT);
2570        let fragment0_batch = RecordBatch::try_new(
2571            schema.clone(),
2572            vec![Arc::new(fragment0_data), Arc::new(fragment0_row_ids)],
2573        )
2574        .unwrap();
2575
2576        // Fragment 1: values 8192-16383 (second zone)
2577        let fragment1_data = arrow_array::Int64Array::from_iter_values(
2578            (ROWS_PER_ZONE_DEFAULT as i64)..((ROWS_PER_ZONE_DEFAULT * 2) as i64),
2579        );
2580        let fragment1_row_ids =
2581            UInt64Array::from_iter_values((0..ROWS_PER_ZONE_DEFAULT).map(|i| i + (1 << 32)));
2582        let fragment1_batch = RecordBatch::try_new(
2583            schema.clone(),
2584            vec![Arc::new(fragment1_data), Arc::new(fragment1_row_ids)],
2585        )
2586        .unwrap();
2587
2588        // Fragment 2: values 16384-16426 (third zone)
2589        let fragment2_data = arrow_array::Int64Array::from_iter_values(
2590            ((ROWS_PER_ZONE_DEFAULT * 2) as i64)..((ROWS_PER_ZONE_DEFAULT * 2 + 42) as i64),
2591        );
2592        let fragment2_row_ids =
2593            UInt64Array::from_iter_values((0..42).map(|i| (i as u64) + (2 << 32)));
2594        let fragment2_batch = RecordBatch::try_new(
2595            schema.clone(),
2596            vec![Arc::new(fragment2_data), Arc::new(fragment2_row_ids)],
2597        )
2598        .unwrap();
2599
2600        // Each fragment is broken into few batches
2601        {
2602            // Create a stream with multiple batches (fragments)
2603            let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2604                schema.clone(),
2605                stream::iter(vec![
2606                    Ok(fragment0_batch.clone()),
2607                    Ok(fragment1_batch.clone()),
2608                    Ok(fragment2_batch.clone()),
2609                ]),
2610            ));
2611            ZoneMapIndexPlugin::train_zonemap_index(
2612                data_stream,
2613                test_store.as_ref(),
2614                Some(ZoneMapIndexBuilderParams::new(5000)),
2615            )
2616            .await
2617            .unwrap();
2618
2619            // Read the index file back and check its contents
2620            let index =
2621                ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
2622                    .await
2623                    .expect("Failed to load ZoneMapIndex");
2624            assert_eq!(index.zones.len(), 5);
2625            assert_eq!(
2626                index.zones,
2627                vec![
2628                    ZoneMapStatistics {
2629                        min: ScalarValue::Int64(Some(0)),
2630                        max: ScalarValue::Int64(Some(4999)),
2631                        null_count: 0,
2632                        nan_count: 0,
2633                        bound: ZoneBound {
2634                            fragment_id: 0,
2635                            start: 0,
2636                            length: 5000,
2637                        },
2638                    },
2639                    ZoneMapStatistics {
2640                        min: ScalarValue::Int64(Some(5000)),
2641                        max: ScalarValue::Int64(Some(8191)),
2642                        null_count: 0,
2643                        nan_count: 0,
2644                        bound: ZoneBound {
2645                            fragment_id: 0,
2646                            start: 5000,
2647                            length: 3192,
2648                        },
2649                    },
2650                    ZoneMapStatistics {
2651                        min: ScalarValue::Int64(Some(8192)),
2652                        max: ScalarValue::Int64(Some(13191)),
2653                        null_count: 0,
2654                        nan_count: 0,
2655                        bound: ZoneBound {
2656                            fragment_id: 1,
2657                            start: 0,
2658                            length: 5000,
2659                        },
2660                    },
2661                    ZoneMapStatistics {
2662                        min: ScalarValue::Int64(Some(13192)),
2663                        max: ScalarValue::Int64(Some(16383)),
2664                        null_count: 0,
2665                        nan_count: 0,
2666                        bound: ZoneBound {
2667                            fragment_id: 1,
2668                            start: 5000,
2669                            length: 3192,
2670                        },
2671                    },
2672                    ZoneMapStatistics {
2673                        min: ScalarValue::Int64(Some(16384)),
2674                        max: ScalarValue::Int64(Some(16425)),
2675                        null_count: 0,
2676                        nan_count: 0,
2677                        bound: ZoneBound {
2678                            fragment_id: 2,
2679                            start: 0,
2680                            length: 42,
2681                        },
2682                    }
2683                ]
2684            );
2685            // Verify nan_count is 0 for all zones (no NaN values in integer data)
2686            for (i, zone) in index.zones.iter().enumerate() {
2687                assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
2688            }
2689
2690            assert_eq!(index.data_type, DataType::Int64);
2691            assert_eq!(index.rows_per_zone, 5000);
2692            assert_eq!(
2693                index.calculate_included_frags().await.unwrap(),
2694                RoaringBitmap::from_iter(0..3)
2695            );
2696
2697            // Verify _rowaddr column values are properly assigned
2698            let verify_data_stream: SendableRecordBatchStream =
2699                Box::pin(RecordBatchStreamAdapter::new(
2700                    schema.clone(),
2701                    stream::iter(vec![
2702                        Ok(fragment0_batch.clone()),
2703                        Ok(fragment1_batch.clone()),
2704                        Ok(fragment2_batch.clone()),
2705                    ]),
2706                ));
2707            let batches: Vec<RecordBatch> = verify_data_stream.try_collect().await.unwrap();
2708
2709            assert_eq!(batches.len(), 3);
2710
2711            // Check fragment 0 _rowaddr values (should start from 0)
2712            let fragment0_rowaddr_col = batches[0].column_by_name(ROW_ADDR).unwrap();
2713            let fragment0_rowaddrs = fragment0_rowaddr_col
2714                .as_any()
2715                .downcast_ref::<UInt64Array>()
2716                .unwrap();
2717            assert_eq!(
2718                fragment0_rowaddrs.values().len(),
2719                ROWS_PER_ZONE_DEFAULT as usize
2720            );
2721            assert_eq!(fragment0_rowaddrs.values()[0], 0);
2722            assert_eq!(
2723                fragment0_rowaddrs.values()[fragment0_rowaddrs.values().len() - 1],
2724                8191
2725            );
2726
2727            // Check fragment 1 _rowaddr values (should start from fragment_id=1)
2728            let fragment1_rowaddr_col = batches[1].column_by_name(ROW_ADDR).unwrap();
2729            let fragment1_rowaddrs = fragment1_rowaddr_col
2730                .as_any()
2731                .downcast_ref::<UInt64Array>()
2732                .unwrap();
2733            assert_eq!(
2734                fragment1_rowaddrs.values().len(),
2735                ROWS_PER_ZONE_DEFAULT as usize
2736            );
2737            assert_eq!(fragment1_rowaddrs.values()[0], 1u64 << 32); // fragment_id=1, local_offset=0
2738            assert_eq!(
2739                fragment1_rowaddrs.values()[fragment1_rowaddrs.values().len() - 1],
2740                8191 | (1u64 << 32)
2741            );
2742
2743            // Check fragment 2 _rowaddr values (should start from fragment_id=2)
2744            let fragment2_rowaddr_col = batches[2].column_by_name(ROW_ADDR).unwrap();
2745            let fragment2_rowaddrs = fragment2_rowaddr_col
2746                .as_any()
2747                .downcast_ref::<UInt64Array>()
2748                .unwrap();
2749            assert_eq!(fragment2_rowaddrs.values().len(), 42);
2750            assert_eq!(fragment2_rowaddrs.values()[0], 2u64 << 32); // fragment_id=2, local_offset=0
2751            assert_eq!(
2752                fragment2_rowaddrs.values()[fragment2_rowaddrs.values().len() - 1],
2753                (2u64 << 32) | 41
2754            );
2755
2756            // Add a few tests for search functionality
2757
2758            // Test range query that spans multiple fragments
2759            let query = SargableQuery::Range(
2760                Bound::Included(ScalarValue::Int64(Some(5000))),
2761                Bound::Included(ScalarValue::Int64(Some(12000))),
2762            );
2763            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2764            // Should include zones from fragments 0 and 1 since they overlap with range 5000-12000
2765            let mut expected = RowAddrTreeMap::new();
2766            // zone 1
2767            expected.insert_range(5000..8192);
2768            // zone 2
2769            expected.insert_range((1u64 << 32)..((1u64 << 32) + 5000));
2770            assert_eq!(result, SearchResult::at_most(expected));
2771
2772            // Test exact match query from zone 2
2773            let query = SargableQuery::Equals(ScalarValue::Int64(Some(8192)));
2774            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2775            // Should include zone 2 since it contains value 8192
2776            let mut expected = RowAddrTreeMap::new();
2777            expected.insert_range((1u64 << 32)..((1u64 << 32) + 5000));
2778            assert_eq!(result, SearchResult::at_most(expected));
2779
2780            // Test exact match query from zone 4
2781            let query = SargableQuery::Equals(ScalarValue::Int64(Some(16385)));
2782            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2783            // Should include zone 4 since it contains value 16385
2784            let mut expected = RowAddrTreeMap::new();
2785            expected.insert_range(2u64 << 32..((2u64 << 32) + 42));
2786            assert_eq!(result, SearchResult::at_most(expected));
2787
2788            // Test query that matches nothing
2789            let query = SargableQuery::Equals(ScalarValue::Int64(Some(99999)));
2790            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2791            assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2792
2793            // Test is_in query
2794            let query = SargableQuery::IsIn(vec![ScalarValue::Int64(Some(16385))]);
2795            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2796            let mut expected = RowAddrTreeMap::new();
2797            expected.insert_range(2u64 << 32..((2u64 << 32) + 42));
2798            assert_eq!(result, SearchResult::at_most(expected));
2799
2800            // Test equals query with null
2801            let query = SargableQuery::Equals(ScalarValue::Int64(None));
2802            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
2803            let mut expected = RowAddrTreeMap::new();
2804            expected.insert_range(0..=16425);
2805            // expected = {:?}", expected
2806            assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
2807        }
2808
2809        //  Each fragment is its own batch
2810        {
2811            // Create a stream with multiple batches (fragments)
2812            let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2813                schema.clone(),
2814                stream::iter(vec![
2815                    Ok(fragment0_batch.clone()),
2816                    Ok(fragment1_batch.clone()),
2817                    Ok(fragment2_batch.clone()),
2818                ]),
2819            ));
2820            ZoneMapIndexPlugin::train_zonemap_index(
2821                data_stream,
2822                test_store.as_ref(),
2823                Some(ZoneMapIndexBuilderParams::default()),
2824            )
2825            .await
2826            .unwrap();
2827
2828            // Read the index file back and check its contents
2829            let index =
2830                ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
2831                    .await
2832                    .expect("Failed to load ZoneMapIndex");
2833            assert_eq!(index.zones.len(), 3);
2834            assert_eq!(
2835                index.zones,
2836                vec![
2837                    ZoneMapStatistics {
2838                        min: ScalarValue::Int64(Some(0)),
2839                        max: ScalarValue::Int64(Some(8191)),
2840                        null_count: 0,
2841                        nan_count: 0,
2842                        bound: ZoneBound {
2843                            fragment_id: 0,
2844                            start: 0,
2845                            length: 8192,
2846                        },
2847                    },
2848                    ZoneMapStatistics {
2849                        min: ScalarValue::Int64(Some(8192)),
2850                        max: ScalarValue::Int64(Some(16383)),
2851                        null_count: 0,
2852                        nan_count: 0,
2853                        bound: ZoneBound {
2854                            fragment_id: 1,
2855                            start: 0,
2856                            length: 8192,
2857                        },
2858                    },
2859                    ZoneMapStatistics {
2860                        min: ScalarValue::Int64(Some(16384)),
2861                        max: ScalarValue::Int64(Some(16425)),
2862                        null_count: 0,
2863                        nan_count: 0,
2864                        bound: ZoneBound {
2865                            fragment_id: 2,
2866                            start: 0,
2867                            length: 42,
2868                        },
2869                    }
2870                ]
2871            );
2872            // Verify nan_count is 0 for all zones (no NaN values in integer data)
2873            for (i, zone) in index.zones.iter().enumerate() {
2874                assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
2875            }
2876
2877            assert_eq!(index.data_type, DataType::Int64);
2878            assert_eq!(index.rows_per_zone, ROWS_PER_ZONE_DEFAULT);
2879            assert_eq!(
2880                index.calculate_included_frags().await.unwrap(),
2881                RoaringBitmap::from_iter(0..3)
2882            );
2883        }
2884
2885        //  All fragments are in the same batch
2886        {
2887            // Create a stream with multiple batches (fragments)
2888            let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2889                schema.clone(),
2890                stream::iter(vec![
2891                    Ok(fragment0_batch.clone()),
2892                    Ok(fragment1_batch.clone()),
2893                    Ok(fragment2_batch.clone()),
2894                ]),
2895            ));
2896            ZoneMapIndexPlugin::train_zonemap_index(
2897                data_stream,
2898                test_store.as_ref(),
2899                Some(ZoneMapIndexBuilderParams::new(ROWS_PER_ZONE_DEFAULT * 3)),
2900            )
2901            .await
2902            .unwrap();
2903
2904            // Read the index file back and check its contents
2905            let index =
2906                ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache(), false)
2907                    .await
2908                    .expect("Failed to load ZoneMapIndex");
2909            assert_eq!(index.zones.len(), 3);
2910            assert_eq!(
2911                index.zones,
2912                vec![
2913                    ZoneMapStatistics {
2914                        min: ScalarValue::Int64(Some(0)),
2915                        max: ScalarValue::Int64(Some(8191)),
2916                        null_count: 0,
2917                        nan_count: 0,
2918                        bound: ZoneBound {
2919                            fragment_id: 0,
2920                            start: 0,
2921                            length: 8192,
2922                        },
2923                    },
2924                    ZoneMapStatistics {
2925                        min: ScalarValue::Int64(Some(8192)),
2926                        max: ScalarValue::Int64(Some(16383)),
2927                        null_count: 0,
2928                        nan_count: 0,
2929                        bound: ZoneBound {
2930                            fragment_id: 1,
2931                            start: 0,
2932                            length: 8192,
2933                        },
2934                    },
2935                    ZoneMapStatistics {
2936                        min: ScalarValue::Int64(Some(16384)),
2937                        max: ScalarValue::Int64(Some(16425)),
2938                        null_count: 0,
2939                        nan_count: 0,
2940                        bound: ZoneBound {
2941                            fragment_id: 2,
2942                            start: 0,
2943                            length: 42,
2944                        },
2945                    }
2946                ]
2947            );
2948            // Verify nan_count is 0 for all zones (no NaN values in integer data)
2949            for (i, zone) in index.zones.iter().enumerate() {
2950                assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
2951            }
2952
2953            assert_eq!(index.data_type, DataType::Int64);
2954            assert_eq!(index.rows_per_zone, ROWS_PER_ZONE_DEFAULT * 3);
2955        }
2956    }
2957
2958    #[tokio::test]
2959    async fn test_fragment_id_assignment() {
2960        // Test that fragment IDs are properly assigned in _rowaddr values
2961        let schema = Arc::new(Schema::new(vec![Field::new(
2962            VALUE_COLUMN_NAME,
2963            DataType::Int32,
2964            false,
2965        )]));
2966
2967        // Create multiple fragments
2968        let fragment0_data = arrow_array::Int32Array::from_iter_values(0..5);
2969        let fragment0_batch =
2970            RecordBatch::try_new(schema.clone(), vec![Arc::new(fragment0_data)]).unwrap();
2971
2972        let fragment1_data = arrow_array::Int32Array::from_iter_values(5..10);
2973        let fragment1_batch =
2974            RecordBatch::try_new(schema.clone(), vec![Arc::new(fragment1_data)]).unwrap();
2975
2976        let aligned_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2977            schema,
2978            stream::iter(vec![Ok(fragment0_batch), Ok(fragment1_batch)]),
2979        ));
2980
2981        let aligned_stream = add_row_addr(aligned_stream);
2982
2983        let batches: Vec<RecordBatch> = aligned_stream.try_collect().await.unwrap();
2984
2985        assert_eq!(batches.len(), 2);
2986
2987        // Check fragment 0 _rowaddr values
2988        let fragment0_rowaddr_col = batches[0].column_by_name(ROW_ADDR).unwrap();
2989        let fragment0_rowaddrs = fragment0_rowaddr_col
2990            .as_any()
2991            .downcast_ref::<UInt64Array>()
2992            .unwrap();
2993
2994        // Fragment 0 should have _rowaddr values: 0, 1, 2, 3, 4
2995        assert_eq!(fragment0_rowaddrs.values(), &[0, 1, 2, 3, 4]);
2996
2997        // Check fragment 1 _rowaddr values
2998        let fragment1_rowaddr_col = batches[1].column_by_name(ROW_ADDR).unwrap();
2999        let fragment1_rowaddrs = fragment1_rowaddr_col
3000            .as_any()
3001            .downcast_ref::<UInt64Array>()
3002            .unwrap();
3003
3004        // Fragment 1 should have _rowaddr values: (1 << 32) | 0, (1 << 32) | 1, etc.
3005        // which is: 4294967296, 4294967297, 4294967298, 4294967299, 4294967300
3006        assert_eq!(
3007            fragment1_rowaddrs.values(),
3008            &[4294967296, 4294967297, 4294967298, 4294967299, 4294967300]
3009        );
3010    }
3011
3012    #[tokio::test]
3013    async fn test_like_prefix_query() {
3014        let tmpdir = TempObjDir::default();
3015        let test_store = Arc::new(LanceIndexStore::new(
3016            Arc::new(ObjectStore::local()),
3017            tmpdir.clone(),
3018            Arc::new(LanceCache::no_cache()),
3019        ));
3020
3021        // Create zones with different string ranges
3022        // Zone 0: ["aaa", "azz"] - should NOT match "foo%"
3023        // Zone 1: ["bar", "baz"] - should NOT match "foo%"
3024        // Zone 2: ["fa", "foz"]  - should match "foo%" (contains potential matches)
3025        // Zone 3: ["fop", "fzz"] - should NOT match "foo%" (all values >= "fop")
3026        // Zone 4: ["foo", "foobar"] - should match "foo%"
3027        // Zone 5: ["gaa", "gzz"] - should NOT match "foo%"
3028
3029        let zones = vec![
3030            ZoneMapStatistics {
3031                min: ScalarValue::Utf8(Some("aaa".to_string())),
3032                max: ScalarValue::Utf8(Some("azz".to_string())),
3033                null_count: 0,
3034                nan_count: 0,
3035                bound: ZoneBound {
3036                    fragment_id: 0,
3037                    start: 0,
3038                    length: 100,
3039                },
3040            },
3041            ZoneMapStatistics {
3042                min: ScalarValue::Utf8(Some("bar".to_string())),
3043                max: ScalarValue::Utf8(Some("baz".to_string())),
3044                null_count: 0,
3045                nan_count: 0,
3046                bound: ZoneBound {
3047                    fragment_id: 1,
3048                    start: 0,
3049                    length: 100,
3050                },
3051            },
3052            ZoneMapStatistics {
3053                min: ScalarValue::Utf8(Some("fa".to_string())),
3054                max: ScalarValue::Utf8(Some("foz".to_string())),
3055                null_count: 0,
3056                nan_count: 0,
3057                bound: ZoneBound {
3058                    fragment_id: 2,
3059                    start: 0,
3060                    length: 100,
3061                },
3062            },
3063            ZoneMapStatistics {
3064                min: ScalarValue::Utf8(Some("fop".to_string())),
3065                max: ScalarValue::Utf8(Some("fzz".to_string())),
3066                null_count: 0,
3067                nan_count: 0,
3068                bound: ZoneBound {
3069                    fragment_id: 3,
3070                    start: 0,
3071                    length: 100,
3072                },
3073            },
3074            ZoneMapStatistics {
3075                min: ScalarValue::Utf8(Some("foo".to_string())),
3076                max: ScalarValue::Utf8(Some("foobar".to_string())),
3077                null_count: 0,
3078                nan_count: 0,
3079                bound: ZoneBound {
3080                    fragment_id: 4,
3081                    start: 0,
3082                    length: 100,
3083                },
3084            },
3085            ZoneMapStatistics {
3086                min: ScalarValue::Utf8(Some("gaa".to_string())),
3087                max: ScalarValue::Utf8(Some("gzz".to_string())),
3088                null_count: 0,
3089                nan_count: 0,
3090                bound: ZoneBound {
3091                    fragment_id: 5,
3092                    start: 0,
3093                    length: 100,
3094                },
3095            },
3096        ];
3097
3098        let index = ZoneMapIndex {
3099            zones,
3100            data_type: DataType::Utf8,
3101            rows_per_zone: ROWS_PER_ZONE_DEFAULT,
3102            use_seeds: false,
3103            store: test_store,
3104            fri: None,
3105            index_cache: WeakLanceCache::from(&LanceCache::no_cache()),
3106            null_rows: None,
3107        };
3108
3109        // Test LikePrefix query for "foo"
3110        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("foo".to_string())));
3111        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3112
3113        // Should match zones 2 and 4 only
3114        let mut expected = RowAddrTreeMap::new();
3115        // Zone 2: fragment 2
3116        expected.insert_range((2u64 << 32)..((2u64 << 32) + 100));
3117        // Zone 4: fragment 4
3118        expected.insert_range((4u64 << 32)..((4u64 << 32) + 100));
3119
3120        assert_eq!(result, SearchResult::at_most(expected));
3121    }
3122
3123    #[tokio::test]
3124    async fn test_like_prefix_edge_cases() {
3125        let tmpdir = TempObjDir::default();
3126        let test_store = Arc::new(LanceIndexStore::new(
3127            Arc::new(ObjectStore::local()),
3128            tmpdir.clone(),
3129            Arc::new(LanceCache::no_cache()),
3130        ));
3131
3132        // Test edge cases for LIKE prefix
3133        let zones = vec![
3134            // Zone with values that contain the prefix exactly
3135            ZoneMapStatistics {
3136                min: ScalarValue::Utf8(Some("test".to_string())),
3137                max: ScalarValue::Utf8(Some("test".to_string())),
3138                null_count: 0,
3139                nan_count: 0,
3140                bound: ZoneBound {
3141                    fragment_id: 0,
3142                    start: 0,
3143                    length: 100,
3144                },
3145            },
3146            // Zone with values that span across the prefix boundary
3147            ZoneMapStatistics {
3148                min: ScalarValue::Utf8(Some("te".to_string())),
3149                max: ScalarValue::Utf8(Some("tf".to_string())),
3150                null_count: 0,
3151                nan_count: 0,
3152                bound: ZoneBound {
3153                    fragment_id: 1,
3154                    start: 0,
3155                    length: 100,
3156                },
3157            },
3158            // Zone completely before prefix
3159            ZoneMapStatistics {
3160                min: ScalarValue::Utf8(Some("abc".to_string())),
3161                max: ScalarValue::Utf8(Some("def".to_string())),
3162                null_count: 0,
3163                nan_count: 0,
3164                bound: ZoneBound {
3165                    fragment_id: 2,
3166                    start: 0,
3167                    length: 100,
3168                },
3169            },
3170        ];
3171
3172        let index = ZoneMapIndex {
3173            zones,
3174            data_type: DataType::Utf8,
3175            rows_per_zone: ROWS_PER_ZONE_DEFAULT,
3176            use_seeds: false,
3177            store: test_store,
3178            fri: None,
3179            index_cache: WeakLanceCache::from(&LanceCache::no_cache()),
3180            null_rows: None,
3181        };
3182
3183        // Test LikePrefix "test"
3184        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("test".to_string())));
3185        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3186
3187        // Should match zones 0 and 1
3188        let mut expected = RowAddrTreeMap::new();
3189        expected.insert_range(0..100); // Zone 0: fragment 0
3190        expected.insert_range((1u64 << 32)..((1u64 << 32) + 100));
3191
3192        assert_eq!(result, SearchResult::at_most(expected));
3193
3194        // Test empty prefix - should match all zones
3195        let query = SargableQuery::LikePrefix(ScalarValue::Utf8(Some("".to_string())));
3196        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3197
3198        let mut expected = RowAddrTreeMap::new();
3199        expected.insert_range(0..100); // Zone 0: fragment 0
3200        expected.insert_range((1u64 << 32)..((1u64 << 32) + 100));
3201        expected.insert_range((2u64 << 32)..((2u64 << 32) + 100));
3202
3203        assert_eq!(result, SearchResult::at_most(expected));
3204    }
3205
3206    #[tokio::test]
3207    async fn test_like_prefix_large_utf8() {
3208        let tmpdir = TempObjDir::default();
3209        let test_store = Arc::new(LanceIndexStore::new(
3210            Arc::new(ObjectStore::local()),
3211            tmpdir.clone(),
3212            Arc::new(LanceCache::no_cache()),
3213        ));
3214
3215        // Test with LargeUtf8 type
3216        let zones = vec![
3217            ZoneMapStatistics {
3218                min: ScalarValue::LargeUtf8(Some("aaa".to_string())),
3219                max: ScalarValue::LargeUtf8(Some("azz".to_string())),
3220                null_count: 0,
3221                nan_count: 0,
3222                bound: ZoneBound {
3223                    fragment_id: 0,
3224                    start: 0,
3225                    length: 100,
3226                },
3227            },
3228            ZoneMapStatistics {
3229                min: ScalarValue::LargeUtf8(Some("foo".to_string())),
3230                max: ScalarValue::LargeUtf8(Some("foobar".to_string())),
3231                null_count: 0,
3232                nan_count: 0,
3233                bound: ZoneBound {
3234                    fragment_id: 1,
3235                    start: 0,
3236                    length: 100,
3237                },
3238            },
3239        ];
3240
3241        let index = ZoneMapIndex {
3242            zones,
3243            data_type: DataType::LargeUtf8,
3244            rows_per_zone: ROWS_PER_ZONE_DEFAULT,
3245            use_seeds: false,
3246            store: test_store,
3247            fri: None,
3248            index_cache: WeakLanceCache::from(&LanceCache::no_cache()),
3249            null_rows: None,
3250        };
3251
3252        // Test LikePrefix with LargeUtf8
3253        let query = SargableQuery::LikePrefix(ScalarValue::LargeUtf8(Some("foo".to_string())));
3254        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
3255
3256        // Should match only zone 1
3257        let mut expected = RowAddrTreeMap::new();
3258        expected.insert_range((1u64 << 32)..((1u64 << 32) + 100));
3259
3260        assert_eq!(result, SearchResult::at_most(expected));
3261    }
3262
3263    #[test]
3264    fn test_compute_next_prefix() {
3265        use super::compute_next_prefix;
3266
3267        // Basic cases
3268        assert_eq!(compute_next_prefix("foo"), Some("fop".to_string()));
3269        assert_eq!(compute_next_prefix("abc"), Some("abd".to_string()));
3270        assert_eq!(compute_next_prefix("a"), Some("b".to_string()));
3271        assert_eq!(compute_next_prefix("z"), Some("{".to_string())); // 'z' + 1 = '{'
3272
3273        // Edge case: prefix with 'z' at the end
3274        assert_eq!(compute_next_prefix("abz"), Some("ab{".to_string()));
3275
3276        // Edge case with tilde (~) which is 0x7E
3277        assert_eq!(compute_next_prefix("ab~"), Some("ab\x7f".to_string()));
3278
3279        // Empty prefix
3280        assert_eq!(compute_next_prefix(""), None);
3281
3282        // Non-ASCII: works correctly by incrementing Unicode code points
3283        // é (U+00E9) -> ê (U+00EA)
3284        assert_eq!(compute_next_prefix("café"), Some("cafê".to_string()));
3285        // 中 (U+4E2D) -> 丮 (U+4E2E)
3286        assert_eq!(compute_next_prefix("abc中"), Some("abc丮".to_string()));
3287        // ÿ (U+00FF) -> Ā (U+0100) - crosses byte boundary but works
3288        assert_eq!(compute_next_prefix("cafÿ"), Some("cafĀ".to_string()));
3289
3290        // Edge case: character just before surrogate range
3291        // U+D7FF -> U+E000 (skips surrogate range U+D800-U+DFFF)
3292        assert_eq!(
3293            compute_next_prefix("a\u{D7FF}"),
3294            Some("a\u{E000}".to_string())
3295        );
3296
3297        // Edge case: max Unicode character U+10FFFF, falls back to previous char
3298        assert_eq!(compute_next_prefix("ab\u{10FFFF}"), Some("ac".to_string()));
3299        // All max characters
3300        assert_eq!(compute_next_prefix("\u{10FFFF}\u{10FFFF}"), None);
3301    }
3302
3303    // When merging zone map segments, if ANY source segment has null_rows = None
3304    // (legacy — null positions unknown), the merged result must also be None.
3305    // The bug: any_null_bitmap is set to true as soon as one source has Some(...),
3306    // and the None sources are silently skipped.  The merged index then has
3307    // null_rows = Some(partial_bitmap), so an IsNull search returns exact results
3308    // that only cover the modern segment's nulls — a false negative for the legacy
3309    // segment whose null positions were never tracked.
3310    #[tokio::test]
3311    async fn test_merge_with_legacy_none_segment_not_treated_as_no_nulls() {
3312        let tmpdir = TempObjDir::default();
3313        let store = Arc::new(LanceIndexStore::new(
3314            Arc::new(ObjectStore::local()),
3315            tmpdir.clone(),
3316            Arc::new(LanceCache::no_cache()),
3317        ));
3318
3319        use arrow_array::{Int32Array, UInt32Array};
3320
3321        // Index A: fragment 0, modern — has a complete null bitmap with 2 known null rows.
3322        let schema_a = Arc::new(Schema::new(vec![
3323            Field::new("min", DataType::Int32, true),
3324            Field::new("max", DataType::Int32, true),
3325            Field::new("null_count", DataType::UInt32, false),
3326            Field::new("nan_count", DataType::UInt32, false),
3327            Field::new("fragment_id", DataType::UInt64, false),
3328            Field::new("zone_start", DataType::UInt64, false),
3329            Field::new("zone_length", DataType::UInt64, false),
3330        ]));
3331        let batch_a = RecordBatch::try_new(
3332            schema_a,
3333            vec![
3334                Arc::new(Int32Array::from(vec![Some(1i32)])) as _,
3335                Arc::new(Int32Array::from(vec![Some(5i32)])) as _,
3336                Arc::new(UInt32Array::from(vec![2u32])) as _,
3337                Arc::new(UInt32Array::from(vec![0u32])) as _,
3338                Arc::new(UInt64Array::from(vec![0u64])) as _,
3339                Arc::new(UInt64Array::from(vec![0u64])) as _,
3340                Arc::new(UInt64Array::from(vec![10u64])) as _,
3341            ],
3342        )
3343        .unwrap();
3344        let mut modern_null_rows = RowAddrTreeMap::new();
3345        modern_null_rows.insert(3); // frag 0 row 3
3346        modern_null_rows.insert(7); // frag 0 row 7
3347        let cache = LanceCache::no_cache();
3348        let index_a = Arc::new(
3349            ZoneMapIndex::try_from_serialized(
3350                batch_a,
3351                store.clone(),
3352                None,
3353                &cache,
3354                10,
3355                Some(modern_null_rows), // modern: complete bitmap
3356                false,
3357            )
3358            .unwrap(),
3359        );
3360
3361        // Index B: fragment 1, legacy — null_rows = None despite null_count = 3.
3362        let schema_b = Arc::new(Schema::new(vec![
3363            Field::new("min", DataType::Int32, true),
3364            Field::new("max", DataType::Int32, true),
3365            Field::new("null_count", DataType::UInt32, false),
3366            Field::new("nan_count", DataType::UInt32, false),
3367            Field::new("fragment_id", DataType::UInt64, false),
3368            Field::new("zone_start", DataType::UInt64, false),
3369            Field::new("zone_length", DataType::UInt64, false),
3370        ]));
3371        let batch_b = RecordBatch::try_new(
3372            schema_b,
3373            vec![
3374                Arc::new(Int32Array::from(vec![Some(10i32)])) as _,
3375                Arc::new(Int32Array::from(vec![Some(20i32)])) as _,
3376                Arc::new(UInt32Array::from(vec![3u32])) as _,
3377                Arc::new(UInt32Array::from(vec![0u32])) as _,
3378                Arc::new(UInt64Array::from(vec![1u64])) as _,
3379                Arc::new(UInt64Array::from(vec![0u64])) as _,
3380                Arc::new(UInt64Array::from(vec![10u64])) as _,
3381            ],
3382        )
3383        .unwrap();
3384        let index_b = Arc::new(
3385            ZoneMapIndex::try_from_serialized(
3386                batch_b,
3387                store.clone(),
3388                None,
3389                &cache,
3390                10,
3391                None, // legacy: null positions unknown
3392                false,
3393            )
3394            .unwrap(),
3395        );
3396
3397        let dest_tmpdir = TempObjDir::default();
3398        let dest_store = Arc::new(LanceIndexStore::new(
3399            Arc::new(ObjectStore::local()),
3400            dest_tmpdir.clone(),
3401            Arc::new(LanceCache::no_cache()),
3402        ));
3403
3404        let all_frags = RoaringBitmap::from_iter([0u32, 1]);
3405        merge_zonemap_indices(
3406            &[index_a.as_ref(), index_b.as_ref()],
3407            dest_store.as_ref(),
3408            &all_frags,
3409        )
3410        .await
3411        .unwrap();
3412
3413        let merged = ZoneMapIndex::load(dest_store.clone(), None, &LanceCache::no_cache(), false)
3414            .await
3415            .unwrap();
3416
3417        // Index B had null_rows = None, so the merged index cannot know all null positions.
3418        // IsNull must NOT return exact — that would be a false negative for fragment 1's nulls.
3419        let result = merged
3420            .search(&SargableQuery::IsNull(), &NoOpMetricsCollector)
3421            .await
3422            .unwrap();
3423
3424        // With the bug: any_null_bitmap=true (from A) → null_rows=Some(A's bitmap only)
3425        //              → IsNull returns exact, missing B's unknown nulls ← FALSE NEGATIVE
3426        // With the fix: any_null_bitmap=false (because B is None) → null_rows=None
3427        //              → IsNull falls through to zone scan → AtMost
3428        assert!(
3429            !result.is_exact(),
3430            "IsNull on a merged index where one source had null_rows=None must not return \
3431             exact; the legacy segment had null_count=3 so its nulls exist at unknown positions"
3432        );
3433    }
3434
3435    // Writes a zonemap file in the legacy format (no null bitmap global buffer),
3436    // simulating an index created before the null bitmap feature was added.
3437    async fn write_legacy_zonemap(store: &dyn IndexStore, null_count: u32) {
3438        use arrow_array::{Int32Array, UInt32Array};
3439        let schema = Arc::new(Schema::new(vec![
3440            Field::new("min", DataType::Int32, true),
3441            Field::new("max", DataType::Int32, true),
3442            Field::new("null_count", DataType::UInt32, false),
3443            Field::new("nan_count", DataType::UInt32, false),
3444            Field::new("fragment_id", DataType::UInt64, false),
3445            Field::new("zone_start", DataType::UInt64, false),
3446            Field::new("zone_length", DataType::UInt64, false),
3447        ]));
3448        let batch = RecordBatch::try_new(
3449            schema.clone(),
3450            vec![
3451                Arc::new(Int32Array::from(vec![Some(0)])) as _,
3452                Arc::new(Int32Array::from(vec![Some(99)])) as _,
3453                Arc::new(UInt32Array::from(vec![null_count])) as _,
3454                Arc::new(UInt32Array::from(vec![0u32])) as _,
3455                Arc::new(UInt64Array::from(vec![0u64])) as _,
3456                Arc::new(UInt64Array::from(vec![0u64])) as _,
3457                Arc::new(UInt64Array::from(vec![100u64])) as _,
3458            ],
3459        )
3460        .unwrap();
3461        let mut file_schema = schema.as_ref().clone();
3462        file_schema
3463            .metadata
3464            .insert(ZONEMAP_SIZE_META_KEY.to_string(), "8192".to_string());
3465        let mut writer = store
3466            .new_index_file(ZONEMAP_FILENAME, Arc::new(file_schema))
3467            .await
3468            .unwrap();
3469        writer.write_record_batch(batch).await.unwrap();
3470        writer.finish().await.unwrap();
3471    }
3472
3473    #[tokio::test]
3474    async fn test_legacy_zonemap_no_null_bitmap() {
3475        let tmpdir = TempObjDir::default();
3476        let store = Arc::new(LanceIndexStore::new(
3477            Arc::new(ObjectStore::local()),
3478            tmpdir.clone(),
3479            Arc::new(LanceCache::no_cache()),
3480        ));
3481
3482        // Write a legacy index with one zone that has nulls but no null bitmap.
3483        write_legacy_zonemap(store.as_ref(), 10).await;
3484
3485        let index = ZoneMapIndex::load(store, None, &LanceCache::no_cache(), false)
3486            .await
3487            .expect("failed to load legacy zonemap");
3488
3489        assert!(
3490            index.null_rows.is_none(),
3491            "legacy index should have no null bitmap"
3492        );
3493
3494        // IS NULL should fall back to the zone-scan path and return AtMost, not Exact.
3495        let result = index
3496            .search(&SargableQuery::IsNull(), &NoOpMetricsCollector)
3497            .await
3498            .unwrap();
3499        assert!(
3500            !result.is_exact(),
3501            "IS NULL on a legacy index should not be exact"
3502        );
3503    }
3504
3505    #[tokio::test]
3506    async fn test_zone_map_seed_writer_round_trip() {
3507        use crate::scalar::seed::IndexSeedWriter;
3508        use crate::scalar::zonemap::ZoneMapSeedWriter;
3509        use arrow_array::{ArrayRef, Int32Array};
3510        use datafusion_common::ScalarValue;
3511
3512        let rows_per_zone = 4u64;
3513        let data_type = DataType::Int32;
3514        let mut writer = ZoneMapSeedWriter::new("test_col", rows_per_zone, data_type).unwrap();
3515
3516        // Batch 1: values 0..4 (fills exactly one zone)
3517        let batch1: ArrayRef = Arc::new(Int32Array::from_iter_values(0..4));
3518        writer.observe_batch(&batch1).unwrap();
3519
3520        // Batch 2: values 10..14 (fills a second zone exactly)
3521        let batch2: ArrayRef = Arc::new(Int32Array::from_iter_values(10..14));
3522        writer.observe_batch(&batch2).unwrap();
3523
3524        // Batch 3: values 20..22 (partial final zone)
3525        let batch3: ArrayRef = Arc::new(Int32Array::from_iter_values(20..22));
3526        writer.observe_batch(&batch3).unwrap();
3527
3528        let bytes = writer.finish().unwrap().expect("should produce bytes");
3529
3530        // Check schema metadata key/value format
3531        assert_eq!(writer.schema_metadata_key(), "lance.seed.test_col");
3532        let meta_val = writer.schema_metadata_value(3);
3533        assert_eq!(meta_val, "3:4");
3534
3535        // Deserialize and verify
3536        let zones = ZoneMapSeedWriter::deserialize_seed(42, &bytes, rows_per_zone).unwrap();
3537        assert_eq!(zones.len(), 3, "expected 3 zones");
3538
3539        // Zone 0: values 0..4 -> min=0, max=3
3540        assert_eq!(zones[0].bound.fragment_id, 42);
3541        assert_eq!(zones[0].bound.start, 0);
3542        assert_eq!(zones[0].min, ScalarValue::Int32(Some(0)));
3543        assert_eq!(zones[0].max, ScalarValue::Int32(Some(3)));
3544        assert_eq!(zones[0].null_count, 0);
3545
3546        // Zone 1: values 10..14 -> min=10, max=13
3547        assert_eq!(zones[1].bound.start, 4);
3548        assert_eq!(zones[1].min, ScalarValue::Int32(Some(10)));
3549        assert_eq!(zones[1].max, ScalarValue::Int32(Some(13)));
3550
3551        // Zone 2: values 20..22 -> min=20, max=21, partial zone of 2 rows
3552        assert_eq!(zones[2].bound.start, 8);
3553        assert_eq!(
3554            zones[2].bound.length, 2,
3555            "partial zone length must be exact"
3556        );
3557        assert_eq!(zones[2].min, ScalarValue::Int32(Some(20)));
3558        assert_eq!(zones[2].max, ScalarValue::Int32(Some(21)));
3559
3560        // Full zones must have the full rows_per_zone length
3561        assert_eq!(zones[0].bound.length, rows_per_zone as usize);
3562        assert_eq!(zones[1].bound.length, rows_per_zone as usize);
3563    }
3564
3565    #[tokio::test]
3566    async fn test_zone_map_seed_writer_spanning_batches() {
3567        use crate::scalar::seed::IndexSeedWriter;
3568        use crate::scalar::zonemap::ZoneMapSeedWriter;
3569        use arrow_array::{ArrayRef, Int32Array};
3570        use datafusion_common::ScalarValue;
3571
3572        let rows_per_zone = 5u64;
3573        let data_type = DataType::Int32;
3574        let mut writer = ZoneMapSeedWriter::new("val", rows_per_zone, data_type).unwrap();
3575
3576        // Single batch with 12 values -> should produce 2 complete zones + 1 partial
3577        let batch: ArrayRef = Arc::new(Int32Array::from_iter_values(0..12));
3578        writer.observe_batch(&batch).unwrap();
3579
3580        let bytes = writer.finish().unwrap().expect("should produce bytes");
3581        let zones = ZoneMapSeedWriter::deserialize_seed(1, &bytes, rows_per_zone).unwrap();
3582        assert_eq!(
3583            zones.len(),
3584            3,
3585            "expected 3 zones from 12 rows with zone size 5"
3586        );
3587
3588        // Zone 0: rows 0..5
3589        assert_eq!(zones[0].bound.length, 5);
3590        assert_eq!(zones[0].min, ScalarValue::Int32(Some(0)));
3591        assert_eq!(zones[0].max, ScalarValue::Int32(Some(4)));
3592        // Zone 1: rows 5..10
3593        assert_eq!(zones[1].bound.length, 5);
3594        assert_eq!(zones[1].min, ScalarValue::Int32(Some(5)));
3595        assert_eq!(zones[1].max, ScalarValue::Int32(Some(9)));
3596        // Zone 2: rows 10..12 (partial)
3597        assert_eq!(zones[2].bound.length, 2, "partial zone length must be 2");
3598        assert_eq!(zones[2].min, ScalarValue::Int32(Some(10)));
3599        assert_eq!(zones[2].max, ScalarValue::Int32(Some(11)));
3600    }
3601
3602    #[tokio::test]
3603    async fn test_zone_map_seed_writer_empty() {
3604        use crate::scalar::seed::IndexSeedWriter;
3605        use crate::scalar::zonemap::ZoneMapSeedWriter;
3606
3607        let mut writer = ZoneMapSeedWriter::new("col", 8, DataType::Int32).unwrap();
3608        let result = writer.finish().unwrap();
3609        assert!(result.is_none(), "empty fragment should return None");
3610    }
3611}