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