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::Any;
16use crate::pbold;
17use crate::scalar::expression::{SargableQueryParser, ScalarQueryParser};
18use crate::scalar::registry::{
19    ScalarIndexPlugin, TrainingCriteria, TrainingOrdering, TrainingRequest,
20};
21use crate::scalar::{
22    BuiltinIndexType, CreatedIndex, SargableQuery, ScalarIndexParams, UpdateCriteria,
23};
24use datafusion::functions_aggregate::min_max::{MaxAccumulator, MinAccumulator};
25use datafusion_expr::Accumulator;
26use lance_core::cache::{LanceCache, WeakLanceCache};
27use serde::{Deserialize, Serialize};
28use std::sync::LazyLock;
29
30use arrow_array::{ArrayRef, RecordBatch, UInt32Array, UInt64Array, new_empty_array};
31use arrow_schema::{DataType, Field};
32use datafusion::execution::SendableRecordBatchStream;
33use datafusion_common::ScalarValue;
34use std::{collections::HashMap, sync::Arc};
35
36use super::{AnyQuery, IndexStore, MetricsCollector, ScalarIndex, SearchResult};
37use crate::scalar::FragReuseIndex;
38use crate::vector::VectorIndex;
39use crate::{Index, IndexType};
40use async_trait::async_trait;
41use deepsize::DeepSizeOf;
42use lance_core::Error;
43use lance_core::Result;
44use roaring::RoaringBitmap;
45
46use super::zoned::{ZoneBound, ZoneProcessor, ZoneTrainer, rebuild_zones, search_zones};
47const ROWS_PER_ZONE_DEFAULT: u64 = 8192; // 1 zone every two batches
48
49const ZONEMAP_FILENAME: &str = "zonemap.lance";
50const ZONEMAP_SIZE_META_KEY: &str = "rows_per_zone";
51const ZONEMAP_INDEX_VERSION: u32 = 0;
52
53/// Basic stats about zonemap index
54#[derive(Debug, PartialEq, Clone)]
55struct ZoneMapStatistics {
56    min: ScalarValue,
57    max: ScalarValue,
58    null_count: u32,
59    // only apply to float type
60    nan_count: u32,
61    // Bound of this zone within the fragment. Persisted as three separate columns
62    // (fragment_id, zone_start, zone_length) in the index file.
63    bound: ZoneBound,
64}
65
66impl DeepSizeOf for ZoneMapStatistics {
67    fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize {
68        // Estimate sizes for ScalarValue
69        let min_size = self.min.size() - std::mem::size_of::<ScalarValue>();
70        let max_size = self.max.size() - std::mem::size_of::<ScalarValue>();
71
72        min_size + max_size
73    }
74}
75
76impl AsRef<ZoneBound> for ZoneMapStatistics {
77    fn as_ref(&self) -> &ZoneBound {
78        &self.bound
79    }
80}
81
82/// ZoneMap index
83/// At high level it's a columnar database technique for predicate push down and scan pruning.
84/// It breaks data into fixed-size chunks called `zones` and store summary statistics(min, max, null_count,
85/// nan_count, fragment_id, local_row_offset) for each zone. It enables efficient filtering by skipping zones that do not contain matching values
86///
87/// This is an inexact filter, similar to a bloom filter. It can return false positives that require rechecking.
88///
89/// Note that it cannot return false negatives.
90/// Input:
91/// * Fragment 1: - 10 rows   -> 0  -> 9
92/// * Fragment 2: - 7 rows    -> 10 -> 16
93/// * Fragment 3: - 4 rows    -> 20 -> 23
94/// * Zone size AKA “rows_per_zone” (from user) - 5
95///
96/// Output:
97/// fragment id | min | max | zone_length
98/// 1           | 0   |  4  | 5
99/// 1           | 5   |  9  | 5
100/// 2           | 10  | 14  | 5
101/// 2           | 15  | 16  | 2
102/// 3           | 20  | 23  | 4
103pub struct ZoneMapIndex {
104    zones: Vec<ZoneMapStatistics>,
105    data_type: DataType,
106    // The maximum rows per zone provided by user
107    rows_per_zone: u64,
108    store: Arc<dyn IndexStore>,
109    fri: Option<Arc<FragReuseIndex>>,
110    index_cache: WeakLanceCache,
111}
112
113impl std::fmt::Debug for ZoneMapIndex {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("ZoneMapIndex")
116            .field("zones", &self.zones)
117            .field("data_type", &self.data_type)
118            .field("rows_per_zone", &self.rows_per_zone)
119            .field("store", &self.store)
120            .field("fri", &self.fri)
121            .field("index_cache", &self.index_cache)
122            .finish()
123    }
124}
125
126impl DeepSizeOf for ZoneMapIndex {
127    fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize {
128        self.zones.deep_size_of_children(context)
129    }
130}
131
132impl ZoneMapIndex {
133    /// Evaluates whether a zone could potentially contain values matching the query
134    /// For NaN, total order is used here
135    /// reference: https://doc.rust-lang.org/std/primitive.f64.html#method.total_cmp
136    fn evaluate_zone_against_query(
137        &self,
138        zone: &ZoneMapStatistics,
139        query: &SargableQuery,
140    ) -> Result<bool> {
141        use std::ops::Bound;
142
143        match query {
144            SargableQuery::IsNull() => {
145                // Zone contains matching values if it has any null values
146                Ok(zone.null_count > 0)
147            }
148            SargableQuery::Equals(target) => {
149                // Zone contains matching values if target falls within [min, max] range
150                // Handle null values - if target is null, check null_count
151                if target.is_null() {
152                    return Ok(zone.null_count > 0);
153                }
154
155                // Handle NaN values - if target is NaN, check nan_count
156                let is_nan = match target {
157                    ScalarValue::Float16(Some(f)) => f.is_nan(),
158                    ScalarValue::Float32(Some(f)) => f.is_nan(),
159                    ScalarValue::Float64(Some(f)) => f.is_nan(),
160                    _ => false,
161                };
162
163                if is_nan {
164                    return Ok(zone.nan_count > 0);
165                }
166
167                // Check if target is within the zone's range
168                // Handle the case where zone.max is NaN (zone contains both finite values and NaN)
169                let min_check = target >= &zone.min;
170                let max_check = match &zone.max {
171                    ScalarValue::Float16(Some(f)) if f.is_nan() => true,
172                    ScalarValue::Float32(Some(f)) if f.is_nan() => true,
173                    ScalarValue::Float64(Some(f)) if f.is_nan() => true,
174                    _ => target <= &zone.max,
175                };
176                Ok(min_check && max_check)
177            }
178            SargableQuery::Range(start, end) => {
179                // Zone overlaps with query range if there's any intersection between
180                // the zone's [min, max] and the query's range
181                let zone_min = &zone.min;
182                let zone_max = &zone.max;
183
184                let start_check = match start {
185                    Bound::Unbounded => true,
186                    Bound::Included(s) => {
187                        // Handle NaN in range bounds - NaN is greater than all finite values
188                        match s {
189                            ScalarValue::Float16(Some(f)) => {
190                                if f.is_nan() {
191                                    return Ok(zone.nan_count > 0);
192                                }
193                            }
194                            ScalarValue::Float32(Some(f)) => {
195                                if f.is_nan() {
196                                    return Ok(zone.nan_count > 0);
197                                }
198                            }
199                            ScalarValue::Float64(Some(f)) => {
200                                if f.is_nan() {
201                                    return Ok(zone.nan_count > 0);
202                                }
203                            }
204                            _ => {}
205                        }
206                        // Handle the case where zone_max is NaN
207                        // If zone_max is NaN, the zone contains both finite values and NaN
208                        // Since we don't know the actual max, we'll be conservative and include the zone
209                        match zone_max {
210                            ScalarValue::Float16(Some(f)) if f.is_nan() => true,
211                            ScalarValue::Float32(Some(f)) if f.is_nan() => true,
212                            ScalarValue::Float64(Some(f)) if f.is_nan() => true,
213                            _ => zone_max >= s,
214                        }
215                    }
216                    Bound::Excluded(s) => {
217                        // Handle NaN in range bounds
218                        match s {
219                            ScalarValue::Float16(Some(f)) => {
220                                if f.is_nan() {
221                                    return Ok(false); // Nothing is greater than NaN
222                                }
223                            }
224                            ScalarValue::Float32(Some(f)) => {
225                                if f.is_nan() {
226                                    return Ok(false); // Nothing is greater than NaN
227                                }
228                            }
229                            ScalarValue::Float64(Some(f)) => {
230                                if f.is_nan() {
231                                    return Ok(false); // Nothing is greater than NaN
232                                }
233                            }
234                            _ => {}
235                        }
236                        zone_max > s
237                    }
238                };
239
240                let end_check = match end {
241                    Bound::Unbounded => true,
242                    Bound::Included(e) => {
243                        // Handle NaN in range bounds
244                        match e {
245                            ScalarValue::Float16(Some(f)) => {
246                                if f.is_nan() {
247                                    // NaN is included, so check if zone has NaN values or finite values
248                                    return Ok(zone.nan_count > 0 || zone_min <= e);
249                                }
250                            }
251                            ScalarValue::Float32(Some(f)) => {
252                                if f.is_nan() {
253                                    return Ok(zone.nan_count > 0 || zone_min <= e);
254                                }
255                            }
256                            ScalarValue::Float64(Some(f)) => {
257                                if f.is_nan() {
258                                    return Ok(zone.nan_count > 0 || zone_min <= e);
259                                }
260                            }
261                            _ => {}
262                        }
263                        zone_min <= e
264                    }
265                    Bound::Excluded(e) => {
266                        // Handle NaN in range bounds
267                        match e {
268                            ScalarValue::Float16(Some(f)) => {
269                                if f.is_nan() {
270                                    // Everything is less than NaN, so include all finite values
271                                    return Ok(true);
272                                }
273                            }
274                            ScalarValue::Float32(Some(f)) => {
275                                if f.is_nan() {
276                                    return Ok(true);
277                                }
278                            }
279                            ScalarValue::Float64(Some(f)) => {
280                                if f.is_nan() {
281                                    return Ok(true);
282                                }
283                            }
284                            _ => {}
285                        }
286                        zone_min < e
287                    }
288                };
289
290                Ok(start_check && end_check)
291            }
292            SargableQuery::IsIn(values) => {
293                // Zone contains matching values if any value in the set falls within [min, max]
294                Ok(values.iter().any(|value| {
295                    if value.is_null() {
296                        zone.null_count > 0
297                    } else {
298                        match value {
299                            ScalarValue::Float16(Some(f)) => {
300                                if f.is_nan() {
301                                    zone.nan_count > 0
302                                } else {
303                                    value >= &zone.min && value <= &zone.max
304                                }
305                            }
306                            ScalarValue::Float32(Some(f)) => {
307                                if f.is_nan() {
308                                    zone.nan_count > 0
309                                } else {
310                                    value >= &zone.min && value <= &zone.max
311                                }
312                            }
313                            ScalarValue::Float64(Some(f)) => {
314                                if f.is_nan() {
315                                    zone.nan_count > 0
316                                } else {
317                                    value >= &zone.min && value <= &zone.max
318                                }
319                            }
320                            _ => value >= &zone.min && value <= &zone.max,
321                        }
322                    }
323                }))
324            }
325            SargableQuery::FullTextSearch(_) => Err(Error::not_supported_source(
326                "full text search is not supported for zonemap indexes".into(),
327            )),
328        }
329    }
330
331    /// Load the scalar index from storage
332    async fn load(
333        store: Arc<dyn IndexStore>,
334        fri: Option<Arc<FragReuseIndex>>,
335        index_cache: &LanceCache,
336    ) -> Result<Arc<Self>>
337    where
338        Self: Sized,
339    {
340        let index_file = store.open_index_file(ZONEMAP_FILENAME).await?;
341        let zone_maps = index_file
342            .read_range(0..index_file.num_rows(), None)
343            .await?;
344        let file_schema = index_file.schema();
345
346        let rows_per_zone: u64 = file_schema
347            .metadata
348            .get(ZONEMAP_SIZE_META_KEY)
349            .and_then(|bs| bs.parse().ok())
350            .unwrap_or(ROWS_PER_ZONE_DEFAULT);
351        Ok(Arc::new(Self::try_from_serialized(
352            zone_maps,
353            store,
354            fri,
355            index_cache,
356            rows_per_zone,
357        )?))
358    }
359
360    fn try_from_serialized(
361        data: RecordBatch,
362        store: Arc<dyn IndexStore>,
363        fri: Option<Arc<FragReuseIndex>>,
364        index_cache: &LanceCache,
365        rows_per_zone: u64,
366    ) -> Result<Self> {
367        // The RecordBatch should have columns: min, max, null_count
368        let min_col = data
369            .column_by_name("min")
370            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'min' column"))?;
371        let max_col = data
372            .column_by_name("max")
373            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'max' column"))?;
374        let null_count_col = data
375            .column_by_name("null_count")
376            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'null_count' column"))?
377            .as_any()
378            .downcast_ref::<arrow_array::UInt32Array>()
379            .ok_or_else(|| {
380                Error::invalid_input("ZoneMapIndex: 'null_count' column is not UInt32")
381            })?;
382        let nan_count_col = data
383            .column_by_name("nan_count")
384            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'nan_count' column"))?
385            .as_any()
386            .downcast_ref::<arrow_array::UInt32Array>()
387            .ok_or_else(|| {
388                Error::invalid_input("ZoneMapIndex: 'nan_count' column is not UInt32")
389            })?;
390        let zone_length = data
391            .column_by_name("zone_length")
392            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'zone_length' column"))?
393            .as_any()
394            .downcast_ref::<arrow_array::UInt64Array>()
395            .ok_or_else(|| {
396                Error::invalid_input("ZoneMapIndex: 'zone_length' column is not UInt64")
397            })?;
398
399        let fragment_id_col = data
400            .column_by_name("fragment_id")
401            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'fragment_id' column"))?
402            .as_any()
403            .downcast_ref::<arrow_array::UInt64Array>()
404            .ok_or_else(|| {
405                Error::invalid_input("ZoneMapIndex: 'fragment_id' column is not UInt64")
406            })?;
407
408        let zone_start_col = data
409            .column_by_name("zone_start")
410            .ok_or_else(|| Error::invalid_input("ZoneMapIndex: missing 'zone_start' column"))?
411            .as_any()
412            .downcast_ref::<arrow_array::UInt64Array>()
413            .ok_or_else(|| {
414                Error::invalid_input("ZoneMapIndex: 'zone_start' column is not UInt64")
415            })?;
416
417        let data_type = min_col.data_type().clone();
418
419        if data.num_rows() == 0 {
420            return Ok(Self {
421                zones: Vec::new(),
422                data_type,
423                rows_per_zone,
424                store,
425                fri,
426                index_cache: WeakLanceCache::from(index_cache),
427            });
428        }
429
430        let num_zones = data.num_rows();
431        let mut zones = Vec::with_capacity(num_zones);
432
433        for i in 0..num_zones {
434            let min = ScalarValue::try_from_array(min_col, i)?;
435            let max = ScalarValue::try_from_array(max_col, i)?;
436            let null_count = null_count_col.value(i);
437            let nan_count = nan_count_col.value(i);
438            zones.push(ZoneMapStatistics {
439                min,
440                max,
441                null_count,
442                nan_count,
443                bound: ZoneBound {
444                    fragment_id: fragment_id_col.value(i),
445                    start: zone_start_col.value(i),
446                    length: zone_length.value(i) as usize,
447                },
448            });
449        }
450
451        Ok(Self {
452            zones,
453            data_type,
454            rows_per_zone,
455            store,
456            fri,
457            index_cache: WeakLanceCache::from(index_cache),
458        })
459    }
460}
461
462#[async_trait]
463impl Index for ZoneMapIndex {
464    fn as_any(&self) -> &dyn Any {
465        self
466    }
467
468    fn as_index(self: Arc<Self>) -> Arc<dyn Index> {
469        self
470    }
471
472    fn as_vector_index(self: Arc<Self>) -> Result<Arc<dyn VectorIndex>> {
473        Err(Error::invalid_input_source(
474            "ZoneMapIndex is not a vector index".into(),
475        ))
476    }
477
478    async fn prewarm(&self) -> Result<()> {
479        // Not much to prewarm
480        Ok(())
481    }
482
483    fn statistics(&self) -> Result<serde_json::Value> {
484        Ok(serde_json::json!({
485            "num_zones": self.zones.len(),
486            "rows_per_zone": self.rows_per_zone,
487        }))
488    }
489
490    fn index_type(&self) -> IndexType {
491        IndexType::ZoneMap
492    }
493
494    async fn calculate_included_frags(&self) -> Result<RoaringBitmap> {
495        let mut frag_ids = RoaringBitmap::new();
496
497        // Loop through zones and add unique fragment IDs to the bitmap
498        for zone in &self.zones {
499            frag_ids.insert(zone.bound.fragment_id as u32);
500        }
501
502        Ok(frag_ids)
503    }
504}
505
506#[async_trait]
507impl ScalarIndex for ZoneMapIndex {
508    async fn search(
509        &self,
510        query: &dyn AnyQuery,
511        metrics: &dyn MetricsCollector,
512    ) -> Result<SearchResult> {
513        let query = query.as_any().downcast_ref::<SargableQuery>().unwrap();
514        search_zones(&self.zones, metrics, |zone| {
515            self.evaluate_zone_against_query(zone, query)
516        })
517    }
518
519    fn can_remap(&self) -> bool {
520        false
521    }
522
523    /// Remap the row ids, creating a new remapped version of this index in `dest_store`
524    async fn remap(
525        &self,
526        _mapping: &HashMap<u64, Option<u64>>,
527        _dest_store: &dyn IndexStore,
528    ) -> Result<CreatedIndex> {
529        Err(Error::invalid_input_source(
530            "ZoneMapIndex does not support remap".into(),
531        ))
532    }
533
534    /// Add the new data , creating an updated version of the index in `dest_store`
535    async fn update(
536        &self,
537        new_data: SendableRecordBatchStream,
538        dest_store: &dyn IndexStore,
539        _valid_old_fragments: Option<&RoaringBitmap>,
540    ) -> Result<CreatedIndex> {
541        // Train new zones for the incoming data stream
542        let schema = new_data.schema();
543        let value_type = schema.field(0).data_type().clone();
544
545        let options = ZoneMapIndexBuilderParams::new(self.rows_per_zone);
546        let processor = ZoneMapProcessor::new(value_type.clone())?;
547        let trainer = ZoneTrainer::new(processor, self.rows_per_zone)?;
548        let updated_zones = rebuild_zones(&self.zones, trainer, new_data).await?;
549
550        // Serialize the combined zones back into the index file
551        let mut builder = ZoneMapIndexBuilder::try_new(options, self.data_type.clone())?;
552        builder.options.rows_per_zone = self.rows_per_zone;
553        builder.maps = updated_zones;
554        builder.write_index(dest_store).await?;
555
556        Ok(CreatedIndex {
557            index_details: prost_types::Any::from_msg(&pbold::ZoneMapIndexDetails::default())
558                .unwrap(),
559            index_version: ZONEMAP_INDEX_VERSION,
560        })
561    }
562
563    fn update_criteria(&self) -> UpdateCriteria {
564        UpdateCriteria::only_new_data(
565            TrainingCriteria::new(TrainingOrdering::Addresses).with_row_addr(),
566        )
567    }
568
569    fn derive_index_params(&self) -> Result<ScalarIndexParams> {
570        let params = serde_json::to_value(ZoneMapIndexBuilderParams::new(self.rows_per_zone))?;
571        Ok(ScalarIndexParams::for_builtin(BuiltinIndexType::ZoneMap).with_params(&params))
572    }
573}
574
575fn default_rows_per_zone() -> u64 {
576    *DEFAULT_ROWS_PER_ZONE
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ZoneMapIndexBuilderParams {
581    #[serde(default = "default_rows_per_zone")]
582    rows_per_zone: u64,
583}
584
585static DEFAULT_ROWS_PER_ZONE: LazyLock<u64> = LazyLock::new(|| {
586    std::env::var("LANCE_ZONEMAP_DEFAULT_ROWS_PER_ZONE")
587        .unwrap_or_else(|_| (ROWS_PER_ZONE_DEFAULT).to_string())
588        .parse()
589        .expect("failed to parse LANCE_ZONEMAP_DEFAULT_ROWS_PER_ZONE")
590});
591
592impl Default for ZoneMapIndexBuilderParams {
593    fn default() -> Self {
594        Self {
595            rows_per_zone: *DEFAULT_ROWS_PER_ZONE,
596        }
597    }
598}
599
600impl ZoneMapIndexBuilderParams {
601    pub fn new(rows_per_zone: u64) -> Self {
602        Self { rows_per_zone }
603    }
604
605    pub fn rows_per_zone(&self) -> u64 {
606        self.rows_per_zone
607    }
608}
609
610// A builder for zonemap index
611pub struct ZoneMapIndexBuilder {
612    options: ZoneMapIndexBuilderParams,
613
614    items_type: DataType,
615    maps: Vec<ZoneMapStatistics>,
616}
617
618impl ZoneMapIndexBuilder {
619    pub fn try_new(options: ZoneMapIndexBuilderParams, items_type: DataType) -> Result<Self> {
620        Ok(Self {
621            options,
622            items_type,
623            maps: Vec::new(),
624        })
625    }
626
627    /// Train the builder using the shared zone trainer.  The input stream must contain
628    /// the value column followed by `_rowaddr`, matching the dataset scan order enforced
629    /// by the scalar index registry.
630    pub async fn train(&mut self, batches_source: SendableRecordBatchStream) -> Result<()> {
631        let processor = ZoneMapProcessor::new(self.items_type.clone())?;
632        let trainer = ZoneTrainer::new(processor, self.options.rows_per_zone)?;
633        self.maps = trainer.train(batches_source).await?;
634        Ok(())
635    }
636
637    fn zonemap_stats_as_batch(&self) -> Result<RecordBatch> {
638        // Flush self.maps as a RecordBatch
639        let mins = if self.maps.is_empty() {
640            new_empty_array(&self.items_type)
641        } else {
642            ScalarValue::iter_to_array(self.maps.iter().map(|stat| stat.min.clone()))?
643        };
644        let maxs = if self.maps.is_empty() {
645            new_empty_array(&self.items_type)
646        } else {
647            ScalarValue::iter_to_array(self.maps.iter().map(|stat| stat.max.clone()))?
648        };
649        let null_counts =
650            UInt32Array::from_iter_values(self.maps.iter().map(|stat| stat.null_count));
651
652        let nan_counts = UInt32Array::from_iter_values(self.maps.iter().map(|stat| stat.nan_count));
653
654        let fragment_ids =
655            UInt64Array::from_iter_values(self.maps.iter().map(|stat| stat.bound.fragment_id));
656
657        let zone_lengths =
658            UInt64Array::from_iter_values(self.maps.iter().map(|stat| stat.bound.length as u64));
659
660        let zone_starts =
661            UInt64Array::from_iter_values(self.maps.iter().map(|stat| stat.bound.start));
662
663        let schema = Arc::new(arrow_schema::Schema::new(vec![
664            // min and max can be null if the entire batch is null values
665            Field::new("min", self.items_type.clone(), true),
666            Field::new("max", self.items_type.clone(), true),
667            Field::new("null_count", DataType::UInt32, false),
668            Field::new("nan_count", DataType::UInt32, false),
669            Field::new("fragment_id", DataType::UInt64, false),
670            Field::new("zone_start", DataType::UInt64, false),
671            Field::new("zone_length", DataType::UInt64, false),
672        ]));
673
674        let columns: Vec<ArrayRef> = vec![
675            mins,
676            maxs,
677            Arc::new(null_counts) as ArrayRef,
678            Arc::new(nan_counts) as ArrayRef,
679            Arc::new(fragment_ids) as ArrayRef,
680            Arc::new(zone_starts) as ArrayRef,
681            Arc::new(zone_lengths) as ArrayRef,
682        ];
683        Ok(RecordBatch::try_new(schema, columns)?)
684    }
685
686    pub async fn write_index(self, index_store: &dyn IndexStore) -> Result<()> {
687        let record_batch = self.zonemap_stats_as_batch()?;
688
689        let mut file_schema = record_batch.schema().as_ref().clone();
690        file_schema.metadata.insert(
691            ZONEMAP_SIZE_META_KEY.to_string(),
692            self.options.rows_per_zone.to_string(),
693        );
694
695        let mut index_file = index_store
696            .new_index_file(ZONEMAP_FILENAME, Arc::new(file_schema))
697            .await?;
698        index_file.write_record_batch(record_batch).await?;
699        index_file.finish().await?;
700        Ok(())
701    }
702}
703
704/// Index-specific processor that computes min/max statistics for each zone while the
705/// trainer takes care of chunking and fragment boundaries.
706struct ZoneMapProcessor {
707    data_type: DataType,
708    min: MinAccumulator,
709    max: MaxAccumulator,
710    null_count: u32,
711    nan_count: u32,
712}
713
714impl ZoneMapProcessor {
715    fn new(data_type: DataType) -> Result<Self> {
716        let min = MinAccumulator::try_new(&data_type)?;
717        let max = MaxAccumulator::try_new(&data_type)?;
718        Ok(Self {
719            data_type,
720            min,
721            max,
722            null_count: 0,
723            nan_count: 0,
724        })
725    }
726
727    fn count_nans(array: &ArrayRef) -> u32 {
728        match array.data_type() {
729            DataType::Float16 => {
730                let array = array
731                    .as_any()
732                    .downcast_ref::<arrow_array::Float16Array>()
733                    .unwrap();
734                array.values().iter().filter(|&&x| x.is_nan()).count() as u32
735            }
736            DataType::Float32 => {
737                let array = array
738                    .as_any()
739                    .downcast_ref::<arrow_array::Float32Array>()
740                    .unwrap();
741                array.values().iter().filter(|&&x| x.is_nan()).count() as u32
742            }
743            DataType::Float64 => {
744                let array = array
745                    .as_any()
746                    .downcast_ref::<arrow_array::Float64Array>()
747                    .unwrap();
748                array.values().iter().filter(|&&x| x.is_nan()).count() as u32
749            }
750            _ => 0,
751        }
752    }
753}
754
755impl ZoneProcessor for ZoneMapProcessor {
756    type ZoneStatistics = ZoneMapStatistics;
757
758    fn process_chunk(&mut self, array: &ArrayRef) -> Result<()> {
759        self.null_count += array.null_count() as u32;
760        self.nan_count += Self::count_nans(array);
761        self.min.update_batch(std::slice::from_ref(array))?;
762        self.max.update_batch(std::slice::from_ref(array))?;
763        Ok(())
764    }
765
766    fn finish_zone(&mut self, bound: ZoneBound) -> Result<Self::ZoneStatistics> {
767        Ok(ZoneMapStatistics {
768            min: self.min.evaluate()?,
769            max: self.max.evaluate()?,
770            null_count: self.null_count,
771            nan_count: self.nan_count,
772            bound,
773        })
774    }
775
776    fn reset(&mut self) -> Result<()> {
777        self.min = MinAccumulator::try_new(&self.data_type)?;
778        self.max = MaxAccumulator::try_new(&self.data_type)?;
779        self.null_count = 0;
780        self.nan_count = 0;
781        Ok(())
782    }
783}
784
785#[derive(Debug, Default)]
786pub struct ZoneMapIndexPlugin;
787
788impl ZoneMapIndexPlugin {
789    async fn train_zonemap_index(
790        batches_source: SendableRecordBatchStream,
791        index_store: &dyn IndexStore,
792        options: Option<ZoneMapIndexBuilderParams>,
793    ) -> Result<()> {
794        // train_zonemap_index: calling scan_aligned_chunks
795        let value_type = batches_source.schema().field(0).data_type().clone();
796
797        let mut builder = ZoneMapIndexBuilder::try_new(options.unwrap_or_default(), value_type)?;
798
799        builder.train(batches_source).await?;
800
801        builder.write_index(index_store).await?;
802        Ok(())
803    }
804}
805
806pub struct ZoneMapIndexTrainingRequest {
807    pub params: ZoneMapIndexBuilderParams,
808    pub criteria: TrainingCriteria,
809}
810
811impl ZoneMapIndexTrainingRequest {
812    pub fn new(params: ZoneMapIndexBuilderParams) -> Self {
813        Self {
814            params,
815            criteria: TrainingCriteria::new(TrainingOrdering::Addresses).with_row_addr(),
816        }
817    }
818}
819
820impl TrainingRequest for ZoneMapIndexTrainingRequest {
821    fn as_any(&self) -> &dyn std::any::Any {
822        self
823    }
824    fn criteria(&self) -> &TrainingCriteria {
825        &self.criteria
826    }
827}
828
829#[async_trait]
830impl ScalarIndexPlugin for ZoneMapIndexPlugin {
831    fn name(&self) -> &str {
832        "ZoneMap"
833    }
834
835    fn new_training_request(
836        &self,
837        params: &str,
838        field: &Field,
839    ) -> Result<Box<dyn TrainingRequest>> {
840        if field.data_type().is_nested() {
841            return Err(Error::invalid_input_source(
842                "A zone map index can only be created on a non-nested field.".into(),
843            ));
844        }
845
846        let params = serde_json::from_str::<ZoneMapIndexBuilderParams>(params)?;
847
848        Ok(Box::new(ZoneMapIndexTrainingRequest::new(params)))
849    }
850
851    fn provides_exact_answer(&self) -> bool {
852        false
853    }
854
855    fn version(&self) -> u32 {
856        ZONEMAP_INDEX_VERSION
857    }
858
859    fn new_query_parser(
860        &self,
861        index_name: String,
862        _index_details: &prost_types::Any,
863    ) -> Option<Box<dyn ScalarQueryParser>> {
864        Some(Box::new(SargableQueryParser::new(index_name, true)))
865    }
866
867    async fn train_index(
868        &self,
869        data: SendableRecordBatchStream,
870        index_store: &dyn IndexStore,
871        request: Box<dyn TrainingRequest>,
872        fragment_ids: Option<Vec<u32>>,
873        _progress: Arc<dyn crate::progress::IndexBuildProgress>,
874    ) -> Result<CreatedIndex> {
875        if fragment_ids.is_some() {
876            return Err(Error::invalid_input_source(
877                "ZoneMap index does not support fragment training".into(),
878            ));
879        }
880
881        let request = (request as Box<dyn std::any::Any>)
882            .downcast::<ZoneMapIndexTrainingRequest>()
883            .map_err(|_| {
884                Error::invalid_input_source(
885                    "must provide training request created by new_training_request".into(),
886                )
887            })?;
888        Self::train_zonemap_index(data, index_store, Some(request.params)).await?;
889        Ok(CreatedIndex {
890            index_details: prost_types::Any::from_msg(&pbold::ZoneMapIndexDetails::default())
891                .unwrap(),
892            index_version: ZONEMAP_INDEX_VERSION,
893        })
894    }
895
896    async fn load_index(
897        &self,
898        index_store: Arc<dyn IndexStore>,
899        _index_details: &prost_types::Any,
900        frag_reuse_index: Option<Arc<FragReuseIndex>>,
901        cache: &LanceCache,
902    ) -> Result<Arc<dyn ScalarIndex>> {
903        Ok(ZoneMapIndex::load(index_store, frag_reuse_index, cache).await? as Arc<dyn ScalarIndex>)
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use crate::scalar::registry::VALUE_COLUMN_NAME;
910    use crate::scalar::{IndexStore, zonemap::ROWS_PER_ZONE_DEFAULT};
911    use std::sync::Arc;
912
913    use crate::scalar::zoned::ZoneBound;
914    use crate::scalar::zonemap::{ZoneMapIndexPlugin, ZoneMapStatistics};
915    use arrow::datatypes::Float32Type;
916    use arrow_array::{Array, RecordBatch, UInt64Array, record_batch};
917    use arrow_schema::{DataType, Field, Schema};
918    use datafusion::execution::SendableRecordBatchStream;
919    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
920    use datafusion_common::ScalarValue;
921    use futures::{StreamExt, TryStreamExt, stream};
922    use lance_core::utils::mask::NullableRowAddrSet;
923    use lance_core::utils::tempfile::TempObjDir;
924    use lance_core::{ROW_ADDR, cache::LanceCache, utils::mask::RowAddrTreeMap};
925    use lance_datafusion::datagen::DatafusionDatagenExt;
926    use lance_datagen::ArrayGeneratorExt;
927    use lance_datagen::{BatchCount, RowCount, array};
928    use lance_io::object_store::ObjectStore;
929
930    use crate::scalar::{
931        SargableQuery, ScalarIndex, SearchResult,
932        lance_format::LanceIndexStore,
933        zonemap::{
934            ZONEMAP_FILENAME, ZONEMAP_SIZE_META_KEY, ZoneMapIndex, ZoneMapIndexBuilderParams,
935        },
936    };
937
938    // Add missing imports for the tests
939    use crate::Index; // Import Index trait to access calculate_included_frags
940    use crate::metrics::NoOpMetricsCollector;
941    use roaring::RoaringBitmap; // Import RoaringBitmap for the test
942    use std::collections::Bound;
943
944    // Adds a _rowaddr column emulating each batch as a new fragment
945    fn add_row_addr(stream: SendableRecordBatchStream) -> SendableRecordBatchStream {
946        let schema = stream.schema();
947        let schema_with_row_addr = Arc::new(Schema::new(vec![
948            schema.field(0).clone(),
949            Field::new(ROW_ADDR, DataType::UInt64, false),
950        ]));
951        let schema = schema_with_row_addr.clone();
952        let stream = stream.enumerate().map(move |(frag_id, batch)| {
953            let batch = batch.unwrap();
954            let row_addr = Arc::new(UInt64Array::from_iter_values(
955                (0..batch.num_rows() as u64).map(|off| off + ((frag_id as u64) << 32)),
956            ));
957            Ok(RecordBatch::try_new(
958                schema_with_row_addr.clone(),
959                vec![batch.column(0).clone(), row_addr],
960            )?)
961        });
962        Box::pin(RecordBatchStreamAdapter::new(schema, stream))
963    }
964
965    #[tokio::test]
966    async fn test_empty_zonemap_index() {
967        let tmpdir = TempObjDir::default();
968        let test_store = Arc::new(LanceIndexStore::new(
969            Arc::new(ObjectStore::local()),
970            tmpdir.clone(),
971            Arc::new(LanceCache::no_cache()),
972        ));
973
974        let data = arrow_array::Int32Array::from(Vec::<i32>::new());
975        let row_ids = arrow_array::UInt64Array::from(Vec::<u64>::new());
976        let schema = Arc::new(Schema::new(vec![
977            Field::new(VALUE_COLUMN_NAME, DataType::Int32, false),
978            Field::new(ROW_ADDR, DataType::UInt64, false),
979        ]));
980        let data =
981            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
982
983        let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
984            schema,
985            stream::once(std::future::ready(Ok(data))),
986        ));
987
988        ZoneMapIndexPlugin::train_zonemap_index(data_stream, test_store.as_ref(), None)
989            .await
990            .unwrap();
991
992        log::debug!("Successfully wrote the index file");
993
994        // Read the index file back and check its contents
995        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
996            .await
997            .expect("Failed to load ZoneMapIndex");
998        assert_eq!(index.zones.len(), 0);
999        assert_eq!(index.data_type, DataType::Int32);
1000        assert_eq!(index.rows_per_zone, ROWS_PER_ZONE_DEFAULT);
1001
1002        // Equals query: null (should match nothing, as there are no nulls)
1003        let query = SargableQuery::Equals(ScalarValue::Int32(None));
1004        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1005        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1006    }
1007
1008    #[tokio::test]
1009    // Test that a zonemap index can be created with null values from few fragments
1010    async fn test_null_zonemap_index() {
1011        let tmpdir = TempObjDir::default();
1012        let test_store = Arc::new(LanceIndexStore::new(
1013            Arc::new(ObjectStore::local()),
1014            tmpdir.clone(),
1015            Arc::new(LanceCache::no_cache()),
1016        ));
1017
1018        let stream = lance_datagen::gen_batch()
1019            .col(
1020                VALUE_COLUMN_NAME,
1021                array::rand::<Float32Type>().with_nulls(&[true, false, false, false, false]),
1022            )
1023            .into_df_stream(RowCount::from(5000), BatchCount::from(10));
1024
1025        // Add _rowaddr column
1026        let stream = add_row_addr(stream);
1027
1028        ZoneMapIndexPlugin::train_zonemap_index(
1029            stream,
1030            test_store.as_ref(),
1031            Some(ZoneMapIndexBuilderParams::new(5000)),
1032        )
1033        .await
1034        .unwrap();
1035
1036        log::debug!("Successfully wrote the index file");
1037
1038        // Read the index file back and check its contents
1039        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
1040            .await
1041            .expect("Failed to load ZoneMapIndex");
1042        assert_eq!(index.zones.len(), 10);
1043        for (i, zone) in index.zones.iter().enumerate() {
1044            assert_eq!(zone.null_count, 1000);
1045            assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
1046            assert_eq!(zone.bound.length, 5000);
1047            assert_eq!(zone.bound.fragment_id, i as u64);
1048        }
1049
1050        // Equals query: null (should match all zones since they contain null values)
1051        let query = SargableQuery::Equals(ScalarValue::Int32(None));
1052        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1053
1054        // Create expected RowAddrTreeMap with all zones since they contain null values
1055        let mut expected = RowAddrTreeMap::new();
1056        for fragment_id in 0..10 {
1057            let start = (fragment_id as u64) << 32;
1058            let end = start + 5000;
1059            expected.insert_range(start..end);
1060        }
1061        assert_eq!(result, SearchResult::at_most(expected));
1062
1063        // Test update - add new data with Float32 values (matching the original data type)
1064        let new_data =
1065            arrow_array::Float32Array::from_iter_values((0..5000).map(|i| i as f32 / 1000.0));
1066        // Create row addresses for fragment 10 (next fragment after 0-9)
1067        let new_row_addr =
1068            UInt64Array::from_iter_values((0..5000).map(|i| (10u64 << 32) | (i as u64)));
1069        let new_schema = Arc::new(Schema::new(vec![
1070            Field::new(VALUE_COLUMN_NAME, DataType::Float32, false), // Match original schema
1071            Field::new(ROW_ADDR, DataType::UInt64, false), // Use _rowaddr as expected by the builder
1072        ]));
1073        let new_data_batch = RecordBatch::try_new(
1074            new_schema.clone(),
1075            vec![Arc::new(new_data), Arc::new(new_row_addr)],
1076        )
1077        .unwrap();
1078        let new_data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
1079            new_schema,
1080            stream::once(std::future::ready(Ok(new_data_batch))),
1081        ));
1082
1083        // Directly pass the stream with proper row addresses instead of using MockTrainingSource
1084        // which would regenerate row addresses starting from 0
1085        index
1086            .update(new_data_stream, test_store.as_ref(), None)
1087            .await
1088            .unwrap();
1089
1090        // Verify the updated index has more zones
1091        let updated_index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
1092            .await
1093            .expect("Failed to load updated ZoneMapIndex");
1094
1095        // Should have original 10 zones + 1 new zone (5000 rows with zone size 5000)
1096        assert_eq!(updated_index.zones.len(), 11);
1097
1098        // Verify the new zone was added
1099        let new_zone = &updated_index.zones[10]; // Last zone should be the new one
1100        assert_eq!(new_zone.bound.fragment_id, 10u64); // New fragment ID
1101        assert_eq!(new_zone.bound.length, 5000);
1102        assert_eq!(new_zone.null_count, 0); // New data has no nulls
1103        assert_eq!(new_zone.nan_count, 0); // New data has no NaN values
1104
1105        // Test search on updated index - search for null values should still work
1106        let query = SargableQuery::Equals(ScalarValue::Float32(None));
1107        let result = updated_index
1108            .search(&query, &NoOpMetricsCollector)
1109            .await
1110            .unwrap();
1111
1112        // Should match original 10 zones (with nulls) but not the new zone (no nulls)
1113        let mut expected = RowAddrTreeMap::new();
1114        for fragment_id in 0..10 {
1115            let start = (fragment_id as u64) << 32;
1116            let end = start + 5000;
1117            expected.insert_range(start..end);
1118        }
1119        assert_eq!(result, SearchResult::at_most(expected));
1120
1121        // Test search for a value that should be in the new zone
1122        let query = SargableQuery::Equals(ScalarValue::Float32(Some(2.5))); // Value 2500/1000 = 2.5
1123        let result = updated_index
1124            .search(&query, &NoOpMetricsCollector)
1125            .await
1126            .unwrap();
1127
1128        // Should match the new zone (fragment 10)
1129        let mut expected = RowAddrTreeMap::new();
1130        let start = 10u64 << 32;
1131        let end = start + 5000;
1132        expected.insert_range(start..end);
1133        assert_eq!(result, SearchResult::at_most(expected));
1134    }
1135
1136    #[tokio::test]
1137    async fn test_zonemap_null_handling_in_queries() {
1138        // Test that zonemap index correctly returns null_list for queries
1139        let tmpdir = TempObjDir::default();
1140        let store = Arc::new(LanceIndexStore::new(
1141            Arc::new(ObjectStore::local()),
1142            tmpdir.clone(),
1143            Arc::new(LanceCache::no_cache()),
1144        ));
1145
1146        // Create test data: [0, 5, null]
1147        let batch = record_batch!(
1148            (VALUE_COLUMN_NAME, Int64, [Some(0), Some(5), None]),
1149            (ROW_ADDR, UInt64, [0, 1, 2])
1150        )
1151        .unwrap();
1152        let schema = batch.schema();
1153        let stream = stream::once(async move { Ok(batch) });
1154        let stream = Box::pin(RecordBatchStreamAdapter::new(schema, stream));
1155
1156        // Train and write the zonemap index
1157        ZoneMapIndexPlugin::train_zonemap_index(stream, store.as_ref(), None)
1158            .await
1159            .unwrap();
1160
1161        let cache = LanceCache::with_capacity(1024 * 1024);
1162        let index = ZoneMapIndex::load(store.clone(), None, &cache)
1163            .await
1164            .unwrap();
1165
1166        // Test 1: Search for value 5 - zonemap should return at_most with all rows
1167        // Since ZoneMap returns AtMost (superset), it's correct to include nulls in the result
1168        let query = SargableQuery::Equals(ScalarValue::Int64(Some(5)));
1169        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1170
1171        match result {
1172            SearchResult::AtMost(row_ids) => {
1173                // Zonemap can't determine exact matches, so it returns all rows in the zone
1174                // This includes nulls because ZoneMap can't prove they don't match
1175                let all_rows: Vec<u64> = row_ids
1176                    .true_rows()
1177                    .row_addrs()
1178                    .unwrap()
1179                    .map(u64::from)
1180                    .collect();
1181                assert_eq!(
1182                    all_rows,
1183                    vec![0, 1, 2],
1184                    "Should return all rows (including nulls) since ZoneMap is inexact"
1185                );
1186
1187                // For AtMost results, nulls are included in the superset
1188                // Downstream processing will handle null filtering
1189            }
1190            _ => panic!("Expected AtMost search result from zonemap"),
1191        }
1192
1193        // Test 2: Range query - should also return all rows as AtMost
1194        let query = SargableQuery::Range(
1195            std::ops::Bound::Included(ScalarValue::Int64(Some(0))),
1196            std::ops::Bound::Included(ScalarValue::Int64(Some(3))),
1197        );
1198        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1199
1200        match result {
1201            SearchResult::AtMost(row_ids) => {
1202                // Again, ZoneMap returns superset including nulls
1203                let all_rows: Vec<u64> = row_ids
1204                    .true_rows()
1205                    .row_addrs()
1206                    .unwrap()
1207                    .map(u64::from)
1208                    .collect();
1209                assert_eq!(
1210                    all_rows,
1211                    vec![0, 1, 2],
1212                    "Should return all rows in zone as possible matches"
1213                );
1214            }
1215            _ => panic!("Expected AtMost search result from zonemap"),
1216        }
1217    }
1218
1219    #[tokio::test]
1220    async fn test_nan_zonemap_index() {
1221        let tmpdir = TempObjDir::default();
1222        let test_store = Arc::new(LanceIndexStore::new(
1223            Arc::new(ObjectStore::local()),
1224            tmpdir.clone(),
1225            Arc::new(LanceCache::no_cache()),
1226        ));
1227
1228        // Create deterministic data with NaN values
1229        // Pattern: [1.0, 2.0, NaN, 3.0, 4.0, 5.0, NaN, 6.0, 7.0, 8.0, ...]
1230        let mut values = Vec::new();
1231        for i in 0..500 {
1232            if i % 5 == 2 {
1233                values.push(f32::NAN);
1234            } else {
1235                // Other values are sequential numbers
1236                values.push(i as f32);
1237            }
1238        }
1239
1240        let float_data = arrow_array::Float32Array::from(values);
1241        let row_ids = UInt64Array::from_iter_values((0..float_data.len()).map(|i| i as u64));
1242        let schema = Arc::new(Schema::new(vec![
1243            Field::new(VALUE_COLUMN_NAME, DataType::Float32, true),
1244            Field::new(ROW_ADDR, DataType::UInt64, false),
1245        ]));
1246        let data = RecordBatch::try_new(
1247            schema.clone(),
1248            vec![Arc::new(float_data.clone()), Arc::new(row_ids)],
1249        )
1250        .unwrap();
1251        let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
1252            schema,
1253            stream::once(std::future::ready(Ok(data))),
1254        ));
1255
1256        ZoneMapIndexPlugin::train_zonemap_index(
1257            data_stream,
1258            test_store.as_ref(),
1259            Some(ZoneMapIndexBuilderParams::new(100)),
1260        )
1261        .await
1262        .unwrap();
1263
1264        // Load the index
1265        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
1266            .await
1267            .expect("Failed to load ZoneMapIndex");
1268
1269        // Should have 5 zones since we have 500 rows and zone size is 100
1270        assert_eq!(index.zones.len(), 5);
1271
1272        // Check that each zone has the expected NaN count
1273        // Each zone has 100 values, and every 5th value (indices 2, 7, 12, ...) is NaN
1274        // So each zone should have 20 NaN values (100/5 = 20)
1275        for (i, zone) in index.zones.iter().enumerate() {
1276            assert_eq!(zone.nan_count, 20, "Zone {} should have 20 NaN values", i);
1277            assert_eq!(
1278                zone.bound.length, 100,
1279                "Zone {} should have zone_length 100",
1280                i
1281            );
1282            assert_eq!(
1283                zone.bound.fragment_id, 0u64,
1284                "Zone {} should have fragment_id 0",
1285                i
1286            );
1287        }
1288
1289        // Test search for NaN values using Equals with NaN
1290        let query = SargableQuery::Equals(ScalarValue::Float32(Some(f32::NAN)));
1291        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1292
1293        // Should match all zones since they all contain NaN values
1294        let mut expected = RowAddrTreeMap::new();
1295        expected.insert_range(0..500); // All rows since NaN is in every zone
1296        assert_eq!(result, SearchResult::at_most(expected));
1297
1298        // Test search for a specific finite value that exists in the data
1299        let query = SargableQuery::Equals(ScalarValue::Float32(Some(5.0)));
1300        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1301
1302        // Should match only the first zone since 5.0 only exists in rows 0-99
1303        let mut expected = RowAddrTreeMap::new();
1304        expected.insert_range(0..100);
1305        assert_eq!(result, SearchResult::at_most(expected));
1306
1307        // Test search for a value that doesn't exist
1308        let query = SargableQuery::Equals(ScalarValue::Float32(Some(1000.0)));
1309        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1310
1311        // Since zones contain NaN values, their max will be NaN, so they will be included
1312        // as potential matches for any finite target (false positive, but acceptable for zone maps)
1313        let mut expected = RowAddrTreeMap::new();
1314        expected.insert_range(0..500);
1315        assert_eq!(result, SearchResult::at_most(expected));
1316
1317        // Test range query that should include finite values
1318        let query = SargableQuery::Range(
1319            Bound::Included(ScalarValue::Float32(Some(0.0))),
1320            Bound::Included(ScalarValue::Float32(Some(250.0))),
1321        );
1322        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1323
1324        // Should match the first three zones since they contain values in the range [0, 250]
1325        let mut expected = RowAddrTreeMap::new();
1326        expected.insert_range(0..300);
1327        assert_eq!(result, SearchResult::at_most(expected));
1328
1329        // Test IsIn query with NaN and finite values
1330        let query = SargableQuery::IsIn(vec![
1331            ScalarValue::Float32(Some(f32::NAN)),
1332            ScalarValue::Float32(Some(5.0)),
1333            ScalarValue::Float32(Some(150.0)), // This value exists in the second zone
1334        ]);
1335        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1336
1337        // Should match all zones since they all contain NaN values
1338        let mut expected = RowAddrTreeMap::new();
1339        expected.insert_range(0..500);
1340        assert_eq!(result, SearchResult::at_most(expected));
1341
1342        // Test range query that excludes all values
1343        let query = SargableQuery::Range(
1344            Bound::Included(ScalarValue::Float32(Some(1000.0))),
1345            Bound::Included(ScalarValue::Float32(Some(2000.0))),
1346        );
1347        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1348
1349        // Since zones contain NaN values, their max will be NaN, so they will be included
1350        // as potential matches for any range query (false positive, but acceptable for zone maps)
1351        let mut expected = RowAddrTreeMap::new();
1352        expected.insert_range(0..500);
1353        assert_eq!(result, SearchResult::at_most(expected));
1354
1355        // Test IsNull query (should match nothing since there are no null values)
1356        let query = SargableQuery::IsNull();
1357        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1358        assert_eq!(result, SearchResult::AtMost(NullableRowAddrSet::empty()));
1359
1360        // Test range queries with NaN bounds
1361        // Range with NaN as start bound (included)
1362        let query = SargableQuery::Range(
1363            Bound::Included(ScalarValue::Float32(Some(f32::NAN))),
1364            Bound::Unbounded,
1365        );
1366        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1367        // Should match all zones since they all contain NaN values
1368        let mut expected = RowAddrTreeMap::new();
1369        expected.insert_range(0..500);
1370        assert_eq!(result, SearchResult::at_most(expected));
1371
1372        // Range with NaN as end bound (included)
1373        let query = SargableQuery::Range(
1374            Bound::Unbounded,
1375            Bound::Included(ScalarValue::Float32(Some(f32::NAN))),
1376        );
1377        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1378        // Should match all zones since they all contain NaN values
1379        let mut expected = RowAddrTreeMap::new();
1380        expected.insert_range(0..500);
1381        assert_eq!(result, SearchResult::at_most(expected));
1382
1383        // Range with NaN as end bound (excluded)
1384        let query = SargableQuery::Range(
1385            Bound::Unbounded,
1386            Bound::Excluded(ScalarValue::Float32(Some(f32::NAN))),
1387        );
1388        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1389        // Should match all zones since everything is less than NaN
1390        let mut expected = RowAddrTreeMap::new();
1391        expected.insert_range(0..500);
1392        assert_eq!(result, SearchResult::at_most(expected));
1393
1394        // Range with NaN as start bound (excluded)
1395        let query = SargableQuery::Range(
1396            Bound::Excluded(ScalarValue::Float32(Some(f32::NAN))),
1397            Bound::Unbounded,
1398        );
1399        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1400        // Should match nothing since nothing is greater than NaN
1401        assert_eq!(result, SearchResult::AtMost(NullableRowAddrSet::empty()));
1402
1403        // Test IsIn query with mixed float types (Float16, Float32, Float64)
1404        let query = SargableQuery::IsIn(vec![
1405            ScalarValue::Float16(Some(half::f16::NAN)),
1406            ScalarValue::Float32(Some(f32::NAN)),
1407            ScalarValue::Float64(Some(f64::NAN)),
1408            ScalarValue::Float32(Some(5.0)),
1409        ]);
1410        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1411        // Should match all zones since they all contain NaN values
1412        let mut expected = RowAddrTreeMap::new();
1413        expected.insert_range(0..500);
1414        assert_eq!(result, SearchResult::at_most(expected));
1415    }
1416
1417    #[tokio::test]
1418    // Test data that belongs to the same fragment but coming from different batches
1419    async fn test_basic_zonemap_index() {
1420        let tmpdir = TempObjDir::default();
1421        let test_store = Arc::new(LanceIndexStore::new(
1422            Arc::new(ObjectStore::local()),
1423            tmpdir.clone(),
1424            Arc::new(LanceCache::no_cache()),
1425        ));
1426
1427        let data = arrow_array::Int32Array::from_iter_values(0..=100);
1428        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
1429        let schema = Arc::new(Schema::new(vec![
1430            Field::new(VALUE_COLUMN_NAME, DataType::Int32, false),
1431            Field::new(ROW_ADDR, DataType::UInt64, false),
1432        ]));
1433        let data =
1434            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
1435        let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
1436            schema,
1437            stream::once(std::future::ready(Ok(data))),
1438        ));
1439
1440        ZoneMapIndexPlugin::train_zonemap_index(
1441            data_stream,
1442            test_store.as_ref(),
1443            Some(ZoneMapIndexBuilderParams::new(100)),
1444        )
1445        .await
1446        .unwrap();
1447
1448        log::debug!("Successfully wrote the index file");
1449
1450        // Read the raw index file back and check its contents
1451        let index_file = test_store.open_index_file(ZONEMAP_FILENAME).await.unwrap();
1452        // Print the metadata from the index_file
1453        let metadata = index_file.schema().metadata.clone();
1454        let record_batch = index_file
1455            .read_record_batch(0, index_file.num_rows() as u64)
1456            .await
1457            .unwrap();
1458        assert_eq!(record_batch.num_rows(), 2);
1459        assert_eq!(
1460            record_batch
1461                .column(0)
1462                .as_any()
1463                .downcast_ref::<arrow_array::Int32Array>()
1464                .unwrap()
1465                .values(),
1466            &[0, 100]
1467        );
1468        assert_eq!(
1469            record_batch
1470                .column(1)
1471                .as_any()
1472                .downcast_ref::<arrow_array::Int32Array>()
1473                .unwrap()
1474                .values(),
1475            &[99, 100]
1476        );
1477        assert_eq!(
1478            record_batch
1479                .column(2)
1480                .as_any()
1481                .downcast_ref::<arrow_array::UInt32Array>()
1482                .unwrap()
1483                .values(),
1484            &[0, 0]
1485        );
1486        assert_eq!(metadata.get(ZONEMAP_SIZE_META_KEY).unwrap(), "100");
1487
1488        // Read the index file back and check its contents
1489        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
1490            .await
1491            .expect("Failed to load ZoneMapIndex");
1492        assert_eq!(index.zones.len(), 2);
1493        assert_eq!(
1494            index.zones,
1495            vec![
1496                ZoneMapStatistics {
1497                    min: ScalarValue::Int32(Some(0)),
1498                    max: ScalarValue::Int32(Some(99)),
1499                    null_count: 0,
1500                    nan_count: 0,
1501                    bound: ZoneBound {
1502                        fragment_id: 0,
1503                        start: 0,
1504                        length: 100,
1505                    },
1506                },
1507                ZoneMapStatistics {
1508                    min: ScalarValue::Int32(Some(100)),
1509                    max: ScalarValue::Int32(Some(100)),
1510                    null_count: 0,
1511                    nan_count: 0,
1512                    bound: ZoneBound {
1513                        fragment_id: 0,
1514                        start: 100,
1515                        length: 1,
1516                    },
1517                }
1518            ]
1519        );
1520        // Verify nan_count is 0 for all zones (no NaN values in integer data)
1521        for (i, zone) in index.zones.iter().enumerate() {
1522            assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
1523        }
1524
1525        assert_eq!(index.data_type, DataType::Int32);
1526        assert_eq!(index.rows_per_zone, 100);
1527        assert_eq!(
1528            index.calculate_included_frags().await.unwrap(),
1529            RoaringBitmap::from_iter(0..1)
1530        );
1531
1532        // Test search functionality
1533
1534        // 1. Range query: (50, +inf)
1535        let query = SargableQuery::Range(
1536            Bound::Excluded(ScalarValue::Int32(Some(50))),
1537            Bound::Unbounded,
1538        );
1539        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1540        assert_eq!(result, SearchResult::at_most(0..=100));
1541
1542        // 2. Range query: [0, 50]
1543        let query = SargableQuery::Range(
1544            Bound::Included(ScalarValue::Int32(Some(0))),
1545            Bound::Included(ScalarValue::Int32(Some(50))),
1546        );
1547        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1548        assert_eq!(result, SearchResult::at_most(0..=99));
1549
1550        // 3. Range query: [101, 200] (should only match the second zone, which is row 100)
1551        let query = SargableQuery::Range(
1552            Bound::Included(ScalarValue::Int32(Some(101))),
1553            Bound::Included(ScalarValue::Int32(Some(200))),
1554        );
1555        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1556        // Only row 100 is in the second zone, but its value is 100, so this should be empty
1557        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1558
1559        // 4. Range query: [100, 100] (should match only the last row)
1560        let query = SargableQuery::Range(
1561            Bound::Included(ScalarValue::Int32(Some(100))),
1562            Bound::Included(ScalarValue::Int32(Some(100))),
1563        );
1564        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1565        assert_eq!(result, SearchResult::at_most(100..=100));
1566
1567        // 5. Equals query: 0 (should match first row)
1568        let query = SargableQuery::Equals(ScalarValue::Int32(Some(0)));
1569        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1570        assert_eq!(result, SearchResult::at_most(0..=99));
1571
1572        // 6. Equals query: 100 (should match only last row)
1573        let query = SargableQuery::Equals(ScalarValue::Int32(Some(100)));
1574        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1575        assert_eq!(result, SearchResult::at_most(100..=100));
1576
1577        // 7. Equals query: 101 (should match nothing)
1578        let query = SargableQuery::Equals(ScalarValue::Int32(Some(101)));
1579        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1580        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1581
1582        // 8. IsNull query (no nulls in data, should match nothing)
1583        let query = SargableQuery::IsNull();
1584        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1585        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1586        // 9. IsIn query: [0, 100, 101, 50]
1587        let query = SargableQuery::IsIn(vec![
1588            ScalarValue::Int32(Some(0)),
1589            ScalarValue::Int32(Some(100)),
1590            ScalarValue::Int32(Some(101)),
1591            ScalarValue::Int32(Some(50)),
1592        ]);
1593        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1594        // 0 and 50 are in the first zone, 100 in the second, 101 is not present
1595        assert_eq!(result, SearchResult::at_most(0..=100));
1596
1597        // 10. IsIn query: [101, 102] (should match nothing)
1598        let query = SargableQuery::IsIn(vec![
1599            ScalarValue::Int32(Some(101)),
1600            ScalarValue::Int32(Some(102)),
1601        ]);
1602        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1603        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1604
1605        // 11. IsIn query: [null] (should match nothing, as there are no nulls)
1606        let query = SargableQuery::IsIn(vec![ScalarValue::Int32(None)]);
1607        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1608        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1609
1610        // 12. Equals query: null (should match nothing, as there are no nulls)
1611        let query = SargableQuery::Equals(ScalarValue::Int32(None));
1612        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1613        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1614    }
1615
1616    #[tokio::test]
1617    // Test zonemap with same fragment from multiple batches
1618    async fn test_complex_zonemap_index() {
1619        let tmpdir = TempObjDir::default();
1620        let test_store = Arc::new(LanceIndexStore::new(
1621            Arc::new(ObjectStore::local()),
1622            tmpdir.clone(),
1623            Arc::new(LanceCache::no_cache()),
1624        ));
1625
1626        // Create data that will produce the expected zonemap zones
1627        let data =
1628            arrow_array::Int64Array::from_iter_values(0..(ROWS_PER_ZONE_DEFAULT * 2 + 42) as i64);
1629        let row_ids = UInt64Array::from_iter_values((0..data.len()).map(|i| i as u64));
1630        let schema = Arc::new(Schema::new(vec![
1631            Field::new(VALUE_COLUMN_NAME, DataType::Int64, false),
1632            Field::new(ROW_ADDR, DataType::UInt64, false),
1633        ]));
1634        let data =
1635            RecordBatch::try_new(schema.clone(), vec![Arc::new(data), Arc::new(row_ids)]).unwrap();
1636        let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
1637            schema,
1638            stream::once(std::future::ready(Ok(data))),
1639        ));
1640
1641        ZoneMapIndexPlugin::train_zonemap_index(
1642            data_stream,
1643            test_store.as_ref(),
1644            Some(ZoneMapIndexBuilderParams::default()),
1645        )
1646        .await
1647        .unwrap();
1648
1649        log::debug!("Successfully wrote the index file");
1650
1651        // Read the index file back and check its contents
1652        let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
1653            .await
1654            .expect("Failed to load ZoneMapIndex");
1655        assert_eq!(index.zones.len(), 3);
1656        assert_eq!(
1657            index.zones,
1658            vec![
1659                ZoneMapStatistics {
1660                    min: ScalarValue::Int64(Some(0)),
1661                    max: ScalarValue::Int64(Some(8191)),
1662                    null_count: 0,
1663                    nan_count: 0,
1664                    bound: ZoneBound {
1665                        fragment_id: 0,
1666                        start: 0,
1667                        length: 8192,
1668                    },
1669                },
1670                ZoneMapStatistics {
1671                    min: ScalarValue::Int64(Some(8192)),
1672                    max: ScalarValue::Int64(Some(16383)),
1673                    null_count: 0,
1674                    nan_count: 0,
1675                    bound: ZoneBound {
1676                        fragment_id: 0,
1677                        start: 8192,
1678                        length: 8192,
1679                    },
1680                },
1681                ZoneMapStatistics {
1682                    min: ScalarValue::Int64(Some(16384)),
1683                    max: ScalarValue::Int64(Some(16425)),
1684                    null_count: 0,
1685                    nan_count: 0,
1686                    bound: ZoneBound {
1687                        fragment_id: 0,
1688                        start: 16384,
1689                        length: 42,
1690                    },
1691                }
1692            ]
1693        );
1694        // Verify nan_count is 0 for all zones (no NaN values in integer data)
1695        for (i, zone) in index.zones.iter().enumerate() {
1696            assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
1697        }
1698
1699        assert_eq!(index.data_type, DataType::Int64);
1700        assert_eq!(index.rows_per_zone, ROWS_PER_ZONE_DEFAULT);
1701        assert_eq!(
1702            index.calculate_included_frags().await.unwrap(),
1703            RoaringBitmap::from_iter(0..1)
1704        );
1705
1706        // TODO: Test search functionality
1707        // Test search functionality
1708
1709        // Search for a value in the first zone
1710        let query = SargableQuery::Equals(ScalarValue::Int64(Some(1000)));
1711        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1712        // Should match row 1000 in fragment 0: row address = (0 << 32) + 1000 = 1000
1713        let mut expected = RowAddrTreeMap::new();
1714        expected.insert_range(0..=8191);
1715        assert_eq!(result, SearchResult::at_most(expected));
1716
1717        // Search for a value in the second zone
1718        let query = SargableQuery::Equals(ScalarValue::Int64(Some(9000)));
1719        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1720        // Should match row 9000 in fragment 0: row address = (0 << 32) + 9000 = 9000
1721        let mut expected = RowAddrTreeMap::new();
1722        expected.insert_range(8192..=16383);
1723        assert_eq!(result, SearchResult::at_most(expected));
1724
1725        // Search for a value not present in any zone
1726        let query = SargableQuery::Equals(ScalarValue::Int64(Some(20000)));
1727        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1728        assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1729
1730        // Search for a range that spans multiple zones
1731        let query = SargableQuery::Range(
1732            Bound::Included(ScalarValue::Int64(Some(9000))),
1733            Bound::Included(ScalarValue::Int64(Some(16400))),
1734        );
1735        let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1736        // Should match all rows from 8000 to 16400 (inclusive)
1737        let mut expected = RowAddrTreeMap::new();
1738        expected.insert_range(8192..=16425);
1739        assert_eq!(result, SearchResult::at_most(expected));
1740    }
1741
1742    #[tokio::test]
1743    // Test zonemap with multiple fragments from different batches
1744    async fn test_multiple_fragments_zonemap() {
1745        let tmpdir = TempObjDir::default();
1746        let test_store = Arc::new(LanceIndexStore::new(
1747            Arc::new(ObjectStore::local()),
1748            tmpdir.clone(),
1749            Arc::new(LanceCache::no_cache()),
1750        ));
1751
1752        let schema = Arc::new(Schema::new(vec![
1753            Field::new(VALUE_COLUMN_NAME, DataType::Int64, false),
1754            Field::new(ROW_ADDR, DataType::UInt64, false),
1755        ]));
1756
1757        // Create multiple fragments with data that will produce expected zones
1758        // Fragment 0: values 0-8191 (first zone)
1759        let fragment0_data =
1760            arrow_array::Int64Array::from_iter_values(0..ROWS_PER_ZONE_DEFAULT as i64);
1761        let fragment0_row_ids = UInt64Array::from_iter_values(0..ROWS_PER_ZONE_DEFAULT);
1762        let fragment0_batch = RecordBatch::try_new(
1763            schema.clone(),
1764            vec![Arc::new(fragment0_data), Arc::new(fragment0_row_ids)],
1765        )
1766        .unwrap();
1767
1768        // Fragment 1: values 8192-16383 (second zone)
1769        let fragment1_data = arrow_array::Int64Array::from_iter_values(
1770            (ROWS_PER_ZONE_DEFAULT as i64)..((ROWS_PER_ZONE_DEFAULT * 2) as i64),
1771        );
1772        let fragment1_row_ids =
1773            UInt64Array::from_iter_values((0..ROWS_PER_ZONE_DEFAULT).map(|i| i + (1 << 32)));
1774        let fragment1_batch = RecordBatch::try_new(
1775            schema.clone(),
1776            vec![Arc::new(fragment1_data), Arc::new(fragment1_row_ids)],
1777        )
1778        .unwrap();
1779
1780        // Fragment 2: values 16384-16426 (third zone)
1781        let fragment2_data = arrow_array::Int64Array::from_iter_values(
1782            ((ROWS_PER_ZONE_DEFAULT * 2) as i64)..((ROWS_PER_ZONE_DEFAULT * 2 + 42) as i64),
1783        );
1784        let fragment2_row_ids =
1785            UInt64Array::from_iter_values((0..42).map(|i| (i as u64) + (2 << 32)));
1786        let fragment2_batch = RecordBatch::try_new(
1787            schema.clone(),
1788            vec![Arc::new(fragment2_data), Arc::new(fragment2_row_ids)],
1789        )
1790        .unwrap();
1791
1792        // Each fragment is broken into few batches
1793        {
1794            // Create a stream with multiple batches (fragments)
1795            let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
1796                schema.clone(),
1797                stream::iter(vec![
1798                    Ok(fragment0_batch.clone()),
1799                    Ok(fragment1_batch.clone()),
1800                    Ok(fragment2_batch.clone()),
1801                ]),
1802            ));
1803            ZoneMapIndexPlugin::train_zonemap_index(
1804                data_stream,
1805                test_store.as_ref(),
1806                Some(ZoneMapIndexBuilderParams::new(5000)),
1807            )
1808            .await
1809            .unwrap();
1810
1811            // Read the index file back and check its contents
1812            let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
1813                .await
1814                .expect("Failed to load ZoneMapIndex");
1815            assert_eq!(index.zones.len(), 5);
1816            assert_eq!(
1817                index.zones,
1818                vec![
1819                    ZoneMapStatistics {
1820                        min: ScalarValue::Int64(Some(0)),
1821                        max: ScalarValue::Int64(Some(4999)),
1822                        null_count: 0,
1823                        nan_count: 0,
1824                        bound: ZoneBound {
1825                            fragment_id: 0,
1826                            start: 0,
1827                            length: 5000,
1828                        },
1829                    },
1830                    ZoneMapStatistics {
1831                        min: ScalarValue::Int64(Some(5000)),
1832                        max: ScalarValue::Int64(Some(8191)),
1833                        null_count: 0,
1834                        nan_count: 0,
1835                        bound: ZoneBound {
1836                            fragment_id: 0,
1837                            start: 5000,
1838                            length: 3192,
1839                        },
1840                    },
1841                    ZoneMapStatistics {
1842                        min: ScalarValue::Int64(Some(8192)),
1843                        max: ScalarValue::Int64(Some(13191)),
1844                        null_count: 0,
1845                        nan_count: 0,
1846                        bound: ZoneBound {
1847                            fragment_id: 1,
1848                            start: 0,
1849                            length: 5000,
1850                        },
1851                    },
1852                    ZoneMapStatistics {
1853                        min: ScalarValue::Int64(Some(13192)),
1854                        max: ScalarValue::Int64(Some(16383)),
1855                        null_count: 0,
1856                        nan_count: 0,
1857                        bound: ZoneBound {
1858                            fragment_id: 1,
1859                            start: 5000,
1860                            length: 3192,
1861                        },
1862                    },
1863                    ZoneMapStatistics {
1864                        min: ScalarValue::Int64(Some(16384)),
1865                        max: ScalarValue::Int64(Some(16425)),
1866                        null_count: 0,
1867                        nan_count: 0,
1868                        bound: ZoneBound {
1869                            fragment_id: 2,
1870                            start: 0,
1871                            length: 42,
1872                        },
1873                    }
1874                ]
1875            );
1876            // Verify nan_count is 0 for all zones (no NaN values in integer data)
1877            for (i, zone) in index.zones.iter().enumerate() {
1878                assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
1879            }
1880
1881            assert_eq!(index.data_type, DataType::Int64);
1882            assert_eq!(index.rows_per_zone, 5000);
1883            assert_eq!(
1884                index.calculate_included_frags().await.unwrap(),
1885                RoaringBitmap::from_iter(0..3)
1886            );
1887
1888            // Verify _rowaddr column values are properly assigned
1889            let verify_data_stream: SendableRecordBatchStream =
1890                Box::pin(RecordBatchStreamAdapter::new(
1891                    schema.clone(),
1892                    stream::iter(vec![
1893                        Ok(fragment0_batch.clone()),
1894                        Ok(fragment1_batch.clone()),
1895                        Ok(fragment2_batch.clone()),
1896                    ]),
1897                ));
1898            let batches: Vec<RecordBatch> = verify_data_stream.try_collect().await.unwrap();
1899
1900            assert_eq!(batches.len(), 3);
1901
1902            // Check fragment 0 _rowaddr values (should start from 0)
1903            let fragment0_rowaddr_col = batches[0].column_by_name(ROW_ADDR).unwrap();
1904            let fragment0_rowaddrs = fragment0_rowaddr_col
1905                .as_any()
1906                .downcast_ref::<UInt64Array>()
1907                .unwrap();
1908            assert_eq!(
1909                fragment0_rowaddrs.values().len(),
1910                ROWS_PER_ZONE_DEFAULT as usize
1911            );
1912            assert_eq!(fragment0_rowaddrs.values()[0], 0);
1913            assert_eq!(
1914                fragment0_rowaddrs.values()[fragment0_rowaddrs.values().len() - 1],
1915                8191
1916            );
1917
1918            // Check fragment 1 _rowaddr values (should start from fragment_id=1)
1919            let fragment1_rowaddr_col = batches[1].column_by_name(ROW_ADDR).unwrap();
1920            let fragment1_rowaddrs = fragment1_rowaddr_col
1921                .as_any()
1922                .downcast_ref::<UInt64Array>()
1923                .unwrap();
1924            assert_eq!(
1925                fragment1_rowaddrs.values().len(),
1926                ROWS_PER_ZONE_DEFAULT as usize
1927            );
1928            assert_eq!(fragment1_rowaddrs.values()[0], 1u64 << 32); // fragment_id=1, local_offset=0
1929            assert_eq!(
1930                fragment1_rowaddrs.values()[fragment1_rowaddrs.values().len() - 1],
1931                8191 | (1u64 << 32)
1932            );
1933
1934            // Check fragment 2 _rowaddr values (should start from fragment_id=2)
1935            let fragment2_rowaddr_col = batches[2].column_by_name(ROW_ADDR).unwrap();
1936            let fragment2_rowaddrs = fragment2_rowaddr_col
1937                .as_any()
1938                .downcast_ref::<UInt64Array>()
1939                .unwrap();
1940            assert_eq!(fragment2_rowaddrs.values().len(), 42);
1941            assert_eq!(fragment2_rowaddrs.values()[0], 2u64 << 32); // fragment_id=2, local_offset=0
1942            assert_eq!(
1943                fragment2_rowaddrs.values()[fragment2_rowaddrs.values().len() - 1],
1944                (2u64 << 32) | 41
1945            );
1946
1947            // Add a few tests for search functionality
1948
1949            // Test range query that spans multiple fragments
1950            let query = SargableQuery::Range(
1951                Bound::Included(ScalarValue::Int64(Some(5000))),
1952                Bound::Included(ScalarValue::Int64(Some(12000))),
1953            );
1954            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1955            // Should include zones from fragments 0 and 1 since they overlap with range 5000-12000
1956            let mut expected = RowAddrTreeMap::new();
1957            // zone 1
1958            expected.insert_range(5000..8192);
1959            // zone 2
1960            expected.insert_range((1u64 << 32)..((1u64 << 32) + 5000));
1961            assert_eq!(result, SearchResult::at_most(expected));
1962
1963            // Test exact match query from zone 2
1964            let query = SargableQuery::Equals(ScalarValue::Int64(Some(8192)));
1965            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1966            // Should include zone 2 since it contains value 8192
1967            let mut expected = RowAddrTreeMap::new();
1968            expected.insert_range((1u64 << 32)..((1u64 << 32) + 5000));
1969            assert_eq!(result, SearchResult::at_most(expected));
1970
1971            // Test exact match query from zone 4
1972            let query = SargableQuery::Equals(ScalarValue::Int64(Some(16385)));
1973            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1974            // Should include zone 4 since it contains value 16385
1975            let mut expected = RowAddrTreeMap::new();
1976            expected.insert_range(2u64 << 32..((2u64 << 32) + 42));
1977            assert_eq!(result, SearchResult::at_most(expected));
1978
1979            // Test query that matches nothing
1980            let query = SargableQuery::Equals(ScalarValue::Int64(Some(99999)));
1981            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1982            assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1983
1984            // Test is_in query
1985            let query = SargableQuery::IsIn(vec![ScalarValue::Int64(Some(16385))]);
1986            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1987            let mut expected = RowAddrTreeMap::new();
1988            expected.insert_range(2u64 << 32..((2u64 << 32) + 42));
1989            assert_eq!(result, SearchResult::at_most(expected));
1990
1991            // Test equals query with null
1992            let query = SargableQuery::Equals(ScalarValue::Int64(None));
1993            let result = index.search(&query, &NoOpMetricsCollector).await.unwrap();
1994            let mut expected = RowAddrTreeMap::new();
1995            expected.insert_range(0..=16425);
1996            // expected = {:?}", expected
1997            assert_eq!(result, SearchResult::at_most(RowAddrTreeMap::new()));
1998        }
1999
2000        //  Each fragment is its own batch
2001        {
2002            // Create a stream with multiple batches (fragments)
2003            let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2004                schema.clone(),
2005                stream::iter(vec![
2006                    Ok(fragment0_batch.clone()),
2007                    Ok(fragment1_batch.clone()),
2008                    Ok(fragment2_batch.clone()),
2009                ]),
2010            ));
2011            ZoneMapIndexPlugin::train_zonemap_index(
2012                data_stream,
2013                test_store.as_ref(),
2014                Some(ZoneMapIndexBuilderParams::default()),
2015            )
2016            .await
2017            .unwrap();
2018
2019            // Read the index file back and check its contents
2020            let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
2021                .await
2022                .expect("Failed to load ZoneMapIndex");
2023            assert_eq!(index.zones.len(), 3);
2024            assert_eq!(
2025                index.zones,
2026                vec![
2027                    ZoneMapStatistics {
2028                        min: ScalarValue::Int64(Some(0)),
2029                        max: ScalarValue::Int64(Some(8191)),
2030                        null_count: 0,
2031                        nan_count: 0,
2032                        bound: ZoneBound {
2033                            fragment_id: 0,
2034                            start: 0,
2035                            length: 8192,
2036                        },
2037                    },
2038                    ZoneMapStatistics {
2039                        min: ScalarValue::Int64(Some(8192)),
2040                        max: ScalarValue::Int64(Some(16383)),
2041                        null_count: 0,
2042                        nan_count: 0,
2043                        bound: ZoneBound {
2044                            fragment_id: 1,
2045                            start: 0,
2046                            length: 8192,
2047                        },
2048                    },
2049                    ZoneMapStatistics {
2050                        min: ScalarValue::Int64(Some(16384)),
2051                        max: ScalarValue::Int64(Some(16425)),
2052                        null_count: 0,
2053                        nan_count: 0,
2054                        bound: ZoneBound {
2055                            fragment_id: 2,
2056                            start: 0,
2057                            length: 42,
2058                        },
2059                    }
2060                ]
2061            );
2062            // Verify nan_count is 0 for all zones (no NaN values in integer data)
2063            for (i, zone) in index.zones.iter().enumerate() {
2064                assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
2065            }
2066
2067            assert_eq!(index.data_type, DataType::Int64);
2068            assert_eq!(index.rows_per_zone, ROWS_PER_ZONE_DEFAULT);
2069            assert_eq!(
2070                index.calculate_included_frags().await.unwrap(),
2071                RoaringBitmap::from_iter(0..3)
2072            );
2073        }
2074
2075        //  All fragments are in the same batch
2076        {
2077            // Create a stream with multiple batches (fragments)
2078            let data_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2079                schema.clone(),
2080                stream::iter(vec![
2081                    Ok(fragment0_batch.clone()),
2082                    Ok(fragment1_batch.clone()),
2083                    Ok(fragment2_batch.clone()),
2084                ]),
2085            ));
2086            ZoneMapIndexPlugin::train_zonemap_index(
2087                data_stream,
2088                test_store.as_ref(),
2089                Some(ZoneMapIndexBuilderParams::new(ROWS_PER_ZONE_DEFAULT * 3)),
2090            )
2091            .await
2092            .unwrap();
2093
2094            // Read the index file back and check its contents
2095            let index = ZoneMapIndex::load(test_store.clone(), None, &LanceCache::no_cache())
2096                .await
2097                .expect("Failed to load ZoneMapIndex");
2098            assert_eq!(index.zones.len(), 3);
2099            assert_eq!(
2100                index.zones,
2101                vec![
2102                    ZoneMapStatistics {
2103                        min: ScalarValue::Int64(Some(0)),
2104                        max: ScalarValue::Int64(Some(8191)),
2105                        null_count: 0,
2106                        nan_count: 0,
2107                        bound: ZoneBound {
2108                            fragment_id: 0,
2109                            start: 0,
2110                            length: 8192,
2111                        },
2112                    },
2113                    ZoneMapStatistics {
2114                        min: ScalarValue::Int64(Some(8192)),
2115                        max: ScalarValue::Int64(Some(16383)),
2116                        null_count: 0,
2117                        nan_count: 0,
2118                        bound: ZoneBound {
2119                            fragment_id: 1,
2120                            start: 0,
2121                            length: 8192,
2122                        },
2123                    },
2124                    ZoneMapStatistics {
2125                        min: ScalarValue::Int64(Some(16384)),
2126                        max: ScalarValue::Int64(Some(16425)),
2127                        null_count: 0,
2128                        nan_count: 0,
2129                        bound: ZoneBound {
2130                            fragment_id: 2,
2131                            start: 0,
2132                            length: 42,
2133                        },
2134                    }
2135                ]
2136            );
2137            // Verify nan_count is 0 for all zones (no NaN values in integer data)
2138            for (i, zone) in index.zones.iter().enumerate() {
2139                assert_eq!(zone.nan_count, 0, "Zone {} should have nan_count = 0", i);
2140            }
2141
2142            assert_eq!(index.data_type, DataType::Int64);
2143            assert_eq!(index.rows_per_zone, ROWS_PER_ZONE_DEFAULT * 3);
2144        }
2145    }
2146
2147    #[tokio::test]
2148    async fn test_fragment_id_assignment() {
2149        // Test that fragment IDs are properly assigned in _rowaddr values
2150        let schema = Arc::new(Schema::new(vec![Field::new(
2151            VALUE_COLUMN_NAME,
2152            DataType::Int32,
2153            false,
2154        )]));
2155
2156        // Create multiple fragments
2157        let fragment0_data = arrow_array::Int32Array::from_iter_values(0..5);
2158        let fragment0_batch =
2159            RecordBatch::try_new(schema.clone(), vec![Arc::new(fragment0_data)]).unwrap();
2160
2161        let fragment1_data = arrow_array::Int32Array::from_iter_values(5..10);
2162        let fragment1_batch =
2163            RecordBatch::try_new(schema.clone(), vec![Arc::new(fragment1_data)]).unwrap();
2164
2165        let aligned_stream: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
2166            schema,
2167            stream::iter(vec![Ok(fragment0_batch), Ok(fragment1_batch)]),
2168        ));
2169
2170        let aligned_stream = add_row_addr(aligned_stream);
2171
2172        let batches: Vec<RecordBatch> = aligned_stream.try_collect().await.unwrap();
2173
2174        assert_eq!(batches.len(), 2);
2175
2176        // Check fragment 0 _rowaddr values
2177        let fragment0_rowaddr_col = batches[0].column_by_name(ROW_ADDR).unwrap();
2178        let fragment0_rowaddrs = fragment0_rowaddr_col
2179            .as_any()
2180            .downcast_ref::<UInt64Array>()
2181            .unwrap();
2182
2183        // Fragment 0 should have _rowaddr values: 0, 1, 2, 3, 4
2184        assert_eq!(fragment0_rowaddrs.values(), &[0, 1, 2, 3, 4]);
2185
2186        // Check fragment 1 _rowaddr values
2187        let fragment1_rowaddr_col = batches[1].column_by_name(ROW_ADDR).unwrap();
2188        let fragment1_rowaddrs = fragment1_rowaddr_col
2189            .as_any()
2190            .downcast_ref::<UInt64Array>()
2191            .unwrap();
2192
2193        // Fragment 1 should have _rowaddr values: (1 << 32) | 0, (1 << 32) | 1, etc.
2194        // which is: 4294967296, 4294967297, 4294967298, 4294967299, 4294967300
2195        assert_eq!(
2196            fragment1_rowaddrs.values(),
2197            &[4294967296, 4294967297, 4294967298, 4294967299, 4294967300]
2198        );
2199    }
2200}