You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@paimon.apache.org by lz...@apache.org on 2026/09/01 03:16:35 UTC

(paimon-rust) branch main updated: refactor: split global index implementations (#767)

This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new a6281ae9 refactor: split global index implementations (#767)
a6281ae9 is described below

commit a6281ae9b2fd1132d64162c287d480f7881e7f2c
Author: Jingsong Lee <ji...@gmail.com>
AuthorDate: Tue Sep 1 11:16:30 2026 +0800

    refactor: split global index implementations (#767)
---
 crates/paimon/src/fm_index/suffix_array.rs         |    4 +-
 .../paimon/src/table/global_index_build_common.rs  |    2 +
 .../src/table/global_index_build_common/vector.rs  |  258 ++
 crates/paimon/src/table/global_index_scanner.rs    | 3864 +-------------------
 .../table/global_index_scanner/deletion_vectors.rs |  122 +
 .../paimon/src/table/global_index_scanner/entry.rs |  212 ++
 .../src/table/global_index_scanner/evaluator.rs    |  609 +++
 .../src/table/global_index_scanner/predicates.rs   |  115 +
 .../src/table/global_index_scanner/query_plan.rs   |   88 +
 .../src/table/global_index_scanner/reader.rs       |  380 ++
 .../src/table/global_index_scanner/row_ranges.rs   |  296 ++
 .../paimon/src/table/global_index_scanner/tests.rs | 2136 +++++++++++
 .../paimon/src/table/lumina_index_build_builder.rs | 1938 +---------
 .../table/lumina_index_build_builder/extraction.rs |  199 +
 .../table/lumina_index_build_builder/planning.rs   |   49 +
 .../src/table/lumina_index_build_builder/tests.rs  | 1257 +++++++
 .../table/lumina_index_build_builder/validation.rs |  135 +
 .../src/table/lumina_index_build_builder/writer.rs |  221 ++
 .../src/table/sorted_global_index_build_builder.rs | 3348 +----------------
 .../extraction.rs                                  |  343 ++
 .../sorted_global_index_build_builder/planning.rs  |  245 ++
 .../sorted_global_index_build_builder/tests.rs     | 2332 ++++++++++++
 .../validation.rs                                  |  179 +
 .../sorted_global_index_build_builder/writer.rs    |  365 ++
 .../paimon/src/table/vindex_index_build_builder.rs | 1876 +---------
 .../table/vindex_index_build_builder/extraction.rs |  190 +
 .../table/vindex_index_build_builder/planning.rs   |   47 +
 .../src/table/vindex_index_build_builder/tests.rs  |  863 +++++
 .../src/table/vindex_index_build_builder/timing.rs |  102 +
 .../table/vindex_index_build_builder/validation.rs |  155 +
 .../src/table/vindex_index_build_builder/writer.rs |  442 +++
 31 files changed, 11464 insertions(+), 10908 deletions(-)

diff --git a/crates/paimon/src/fm_index/suffix_array.rs b/crates/paimon/src/fm_index/suffix_array.rs
index aa18983a..b40f4493 100644
--- a/crates/paimon/src/fm_index/suffix_array.rs
+++ b/crates/paimon/src/fm_index/suffix_array.rs
@@ -297,10 +297,10 @@ mod tests {
         for length in 1..=80 {
             for _ in 0..30 {
                 let alphabet = random.gen_range(1..=12);
-                let mut text = (0..length)
+                let text = (0..length)
                     .map(|_| random.gen_range(1..=alphabet) as u16)
+                    .chain(std::iter::once(0))
                     .collect::<Vec<_>>();
-                text.push(0);
                 assert_eq!(build(&text, alphabet).unwrap(), naive(&text));
             }
         }
diff --git a/crates/paimon/src/table/global_index_build_common.rs b/crates/paimon/src/table/global_index_build_common.rs
index 355a56e4..3b255a25 100644
--- a/crates/paimon/src/table/global_index_build_common.rs
+++ b/crates/paimon/src/table/global_index_build_common.rs
@@ -28,6 +28,8 @@ use crate::spec::{FileKind, IndexManifest};
 use crate::table::{merge_row_ranges, RowRange, Table};
 use crate::{Error, Result};
 
+pub(crate) mod vector;
+
 /// Java `sameExtraFieldIds`: null/empty are equal; otherwise exact ordered equality.
 pub(crate) fn same_extra_field_ids(a: Option<&[i32]>, b: Option<&[i32]>) -> bool {
     let a = a.unwrap_or(&[]);
diff --git a/crates/paimon/src/table/global_index_build_common/vector.rs b/crates/paimon/src/table/global_index_build_common/vector.rs
new file mode 100644
index 00000000..72a37d68
--- /dev/null
+++ b/crates/paimon/src/table/global_index_build_common/vector.rs
@@ -0,0 +1,258 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Shared shard planning for contiguous vector global indexes.
+
+use crate::spec::{
+    bucket_dir_name, BinaryRow, CoreOptions, DataField, DataFileMeta, DataType, FileKind,
+    ManifestEntry, PartitionComputer,
+};
+use crate::table::source::exclude_row_ranges;
+use crate::table::{RowRange, Table};
+use crate::{Error, Result};
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct VectorIndexShard {
+    pub(crate) partition: BinaryRow,
+    pub(crate) partition_bytes: Vec<u8>,
+    pub(crate) files: Vec<DataFileMeta>,
+    pub(crate) row_range_start: i64,
+    pub(crate) row_range_end: i64,
+    pub(crate) snapshot_id: i64,
+    pub(crate) source_bucket: i32,
+    pub(crate) total_buckets: i32,
+    pub(crate) bucket_path: String,
+}
+
+pub(crate) fn find_index_field<'a>(table: &'a Table, column: &str) -> Result<&'a DataField> {
+    table
+        .schema()
+        .fields()
+        .iter()
+        .find(|field| field.name() == column)
+        .ok_or_else(|| Error::ColumnNotExist {
+            full_name: table.identifier().full_name(),
+            column: column.to_string(),
+        })
+}
+
+pub(crate) fn validate_vector_field(field: &DataField, index_name: &str) -> Result<()> {
+    let is_array_float = matches!(
+        field.data_type(),
+        DataType::Array(array) if matches!(array.element_type(), DataType::Float(_))
+    );
+    let is_vector_float = matches!(
+        field.data_type(),
+        DataType::Vector(vector) if matches!(vector.element_type(), DataType::Float(_))
+    );
+    if !is_array_float && !is_vector_float {
+        return Err(Error::DataInvalid {
+            message: format!(
+                "{index_name} index requires ARRAY<FLOAT> or VECTOR<FLOAT> column, got {:?} for column '{}'",
+                field.data_type(),
+                field.name()
+            ),
+            source: None,
+        });
+    }
+    Ok(())
+}
+
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn plan_vector_index_shards(
+    table_location: &str,
+    partition_keys: &[String],
+    schema_fields: &[DataField],
+    core_options: &CoreOptions,
+    snapshot_id: i64,
+    entries: Vec<ManifestEntry>,
+    rows_per_shard: i64,
+    indexed: &[RowRange],
+    index_name: &str,
+) -> Result<Vec<VectorIndexShard>> {
+    if rows_per_shard <= 0 {
+        return Err(Error::DataInvalid {
+            message: format!(
+                "Option 'global-index.row-count-per-shard' must be greater than 0, got: {rows_per_shard}"
+            ),
+            source: None,
+        });
+    }
+
+    let mut by_partition_bucket: HashMap<(Vec<u8>, i32, i32), Vec<DataFileMeta>> = HashMap::new();
+    for entry in entries {
+        if *entry.kind() != FileKind::Add {
+            continue;
+        }
+        if entry.file().first_row_id.is_none() {
+            return Err(missing_row_id_error(entry.file(), index_name));
+        }
+        let (partition, bucket, total_buckets, file) = entry.into_parts();
+        by_partition_bucket
+            .entry((partition, bucket, total_buckets))
+            .or_default()
+            .push(file);
+    }
+
+    let mut result = Vec::new();
+    for ((partition_bytes, source_bucket, total_buckets), files) in by_partition_bucket {
+        let partition = if partition_keys.is_empty() {
+            BinaryRow::new(0)
+        } else {
+            BinaryRow::from_serialized_bytes(&partition_bytes)?
+        };
+        let bucket_path = bucket_path(
+            table_location,
+            partition_keys,
+            schema_fields,
+            core_options,
+            &partition,
+            source_bucket,
+        )?;
+        let mut files_by_shard: HashMap<i64, Vec<DataFileMeta>> = HashMap::new();
+        for file in files {
+            let (file_start, file_end) = file
+                .row_id_range()
+                .ok_or_else(|| missing_row_id_error(&file, index_name))?;
+            let start_shard = file_start / rows_per_shard;
+            let end_shard = file_end / rows_per_shard;
+            for shard_id in start_shard..=end_shard {
+                files_by_shard
+                    .entry(shard_id * rows_per_shard)
+                    .or_default()
+                    .push(file.clone());
+            }
+        }
+
+        let mut shard_starts = files_by_shard.keys().copied().collect::<Vec<_>>();
+        shard_starts.sort_unstable();
+        for shard_start in shard_starts {
+            let shard_end = shard_start + rows_per_shard - 1;
+            let mut shard_files = files_by_shard.remove(&shard_start).unwrap_or_default();
+            shard_files.sort_by_key(|file| file.first_row_id);
+            for group in group_contiguous_files(shard_files, index_name)? {
+                let group_start = group
+                    .first()
+                    .and_then(|file| file.first_row_id)
+                    .expect("planned groups are non-empty and row-id assigned");
+                let group_end = group
+                    .iter()
+                    .map(|file| file.row_id_range().unwrap().1)
+                    .max()
+                    .unwrap();
+                let coverage_start = group_start.max(shard_start);
+                let coverage_end = group_end.min(shard_end);
+                let build_segments =
+                    exclude_row_ranges(&[RowRange::new(coverage_start, coverage_end)], indexed);
+                for segment in build_segments {
+                    result.push(VectorIndexShard {
+                        partition: partition.clone(),
+                        partition_bytes: partition_bytes.clone(),
+                        files: group.clone(),
+                        row_range_start: segment.from(),
+                        row_range_end: segment.to(),
+                        snapshot_id,
+                        source_bucket,
+                        total_buckets,
+                        bucket_path: bucket_path.clone(),
+                    });
+                }
+            }
+        }
+    }
+    result.sort_by(|left, right| {
+        left.partition
+            .to_serialized_bytes()
+            .cmp(&right.partition.to_serialized_bytes())
+            .then(left.source_bucket.cmp(&right.source_bucket))
+            .then(left.row_range_start.cmp(&right.row_range_start))
+    });
+    Ok(result)
+}
+
+fn missing_row_id_error(file: &DataFileMeta, index_name: &str) -> Error {
+    Error::DataInvalid {
+        message: format!(
+            "Data file '{}' is missing first_row_id; cannot build a complete {index_name} index",
+            file.file_name
+        ),
+        source: None,
+    }
+}
+
+fn group_contiguous_files(
+    mut files: Vec<DataFileMeta>,
+    index_name: &str,
+) -> Result<Vec<Vec<DataFileMeta>>> {
+    if files.is_empty() {
+        return Ok(Vec::new());
+    }
+    files.sort_by_key(|file| file.first_row_id);
+    let mut groups = Vec::new();
+    let mut current = Vec::new();
+    let mut current_end = None;
+    for file in files {
+        let (file_start, file_end) = file
+            .row_id_range()
+            .ok_or_else(|| missing_row_id_error(&file, index_name))?;
+        match current_end {
+            None => {
+                current.push(file);
+                current_end = Some(file_end);
+            }
+            Some(end) if file_start <= end + 1 => {
+                current.push(file);
+                current_end = Some(end.max(file_end));
+            }
+            Some(_) => {
+                groups.push(std::mem::take(&mut current));
+                current.push(file);
+                current_end = Some(file_end);
+            }
+        }
+    }
+    if !current.is_empty() {
+        groups.push(current);
+    }
+    Ok(groups)
+}
+
+fn bucket_path(
+    table_location: &str,
+    partition_keys: &[String],
+    schema_fields: &[DataField],
+    core_options: &CoreOptions,
+    partition: &BinaryRow,
+    bucket: i32,
+) -> Result<String> {
+    let base = table_location.trim_end_matches('/');
+    if partition_keys.is_empty() {
+        return Ok(format!("{base}/{}", bucket_dir_name(bucket)));
+    }
+    let computer = PartitionComputer::new(
+        partition_keys,
+        schema_fields,
+        core_options.partition_default_name(),
+        core_options.legacy_partition_name(),
+    )?;
+    Ok(format!(
+        "{base}/{}{}",
+        computer.generate_partition_path(partition)?,
+        bucket_dir_name(bucket)
+    ))
+}
diff --git a/crates/paimon/src/table/global_index_scanner.rs b/crates/paimon/src/table/global_index_scanner.rs
index 7d137ce9..746fb8a0 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -20,30 +20,33 @@
 //!
 //! Reference: [org.apache.paimon.index.GlobalIndexScanner](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexScanner.java)
 
-use super::bitmap_global_index_format::{
-    is_bitmap_floating_residual_sensitive_op, make_bitmap_key_comparator, serialize_bitmap_datum,
+mod deletion_vectors;
+mod entry;
+mod evaluator;
+mod predicates;
+mod query_plan;
+mod reader;
+mod row_ranges;
+
+pub(crate) use deletion_vectors::deleted_row_ranges_for_data_evolution_dvs;
+use entry::{validate_fm_file_sets, GlobalIndexEntry, GlobalIndexEntryMeta, GlobalIndexFileKind};
+use row_ranges::unindexed_ranges_for_indexed_coverage;
+pub(crate) use row_ranges::{
+    search_limit_with_deleted_rows, unindexed_ranges_for_global_index_entries, RowRangeIndex,
 };
-use super::bitmap_global_index_reader::BitmapGlobalIndexReader;
+
 use super::global_index_types::{
     normalize_queryable_global_index_type, BITMAP_GLOBAL_INDEX_TYPE, BTREE_GLOBAL_INDEX_TYPE,
     FM_GLOBAL_INDEX_TYPE, MULTIVALUE_GLOBAL_INDEX_TYPE,
 };
-use crate::btree::query::{extract_between, BetweenInfo, IndexQuery};
-use crate::btree::{make_key_comparator, serialize_datum, BTreeIndexMeta, BTreeIndexReader};
-use crate::deletion_vector::DeletionVectorFactory;
-use crate::fm_index::{manifest_row_range, FMGlobalIndexReader, FMReadContext, FMReadOptions};
+use crate::btree::{BTreeIndexMeta, BTreeIndexReader};
+use crate::fm_index::{manifest_row_range, FMReadContext, FMReadOptions};
 use crate::io::FileIO;
-use crate::spec::{
-    DataField, DataType, Datum, FileKind, GlobalIndexSearchMode, IndexFileMeta, IndexManifestEntry,
-    Predicate, PredicateOperator,
-};
-use crate::table::{DeletionFile, RowRange, Table};
+use crate::spec::{DataField, FileKind, GlobalIndexSearchMode, IndexManifestEntry, Predicate};
+use crate::table::RowRange;
 use crate::{Error, Result};
-use futures::{StreamExt, TryStreamExt};
-use roaring::RoaringTreemap;
 use std::cmp::Ordering;
-use std::collections::{HashMap, HashSet};
-use std::future::Future;
+use std::collections::HashMap;
 use std::sync::{Arc, Mutex};
 use tokio::sync::Semaphore;
 
@@ -52,34 +55,9 @@ use std::sync::atomic::{AtomicUsize as TestAtomicUsize, Ordering as TestOrdering
 
 type BoxedCmp = Box<dyn Fn(&[u8], &[u8]) -> Ordering + Send + Sync>;
 
-type EvaluateFuture<'a> = std::pin::Pin<
-    Box<dyn std::future::Future<Output = Result<Option<GlobalIndexScanResult>>> + Send + 'a>,
->;
-
-type PredicateTuple<'a> = (PredicateOperator, &'a [Datum], &'a DataType);
-
 const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
 const INDEX_DIR: &str = "index";
 
-async fn try_fold_bounded<T, Fut, Acc, Fold>(
-    futures: impl IntoIterator<Item = Fut>,
-    max_concurrency: usize,
-    mut accumulator: Acc,
-    mut fold: Fold,
-) -> Result<Acc>
-where
-    Fut: Future<Output = Result<T>>,
-    Fold: FnMut(&mut Acc, T),
-{
-    debug_assert!(max_concurrency > 0);
-    let stream = futures::stream::iter(futures).buffer_unordered(max_concurrency);
-    futures::pin_mut!(stream);
-    while let Some(value) = stream.try_next().await? {
-        fold(&mut accumulator, value);
-    }
-    Ok(accumulator)
-}
-
 #[cfg(test)]
 #[derive(Default)]
 struct QueryIoProbe {
@@ -114,12 +92,6 @@ impl Drop for QueryIoProbeGuard<'_> {
     }
 }
 
-struct GlobalIndexScanResult {
-    row_ranges: Vec<RowRange>,
-    evaluated_field_ids: HashSet<i32>,
-    indexed_coverage: Vec<RowRange>,
-}
-
 /// Evaluates global index predicates and returns matching row ranges.
 ///
 /// The scanner filters index manifest entries for global index files,
@@ -149,283 +121,6 @@ pub(crate) struct GlobalIndexScanner {
     query_io_probe: Option<Arc<QueryIoProbe>>,
 }
 
-/// A resolved global index entry with parsed metadata.
-struct GlobalIndexEntry {
-    file_name: String,
-    index_type: GlobalIndexFileKind,
-    file_size: i64,
-    row_range_start: i64,
-    row_range_end: i64,
-    meta: GlobalIndexEntryMeta,
-}
-
-fn sorted_entry_meta(entry: &GlobalIndexEntry) -> &BTreeIndexMeta {
-    match &entry.meta {
-        GlobalIndexEntryMeta::Sorted(meta) => meta,
-        GlobalIndexEntryMeta::FM { .. } => unreachable!("FM entries do not have sorted metadata"),
-    }
-}
-
-enum GlobalIndexEntryMeta {
-    Sorted(BTreeIndexMeta),
-    FM {
-        bytes: Vec<u8>,
-        first_row_id: u64,
-        row_count: u64,
-    },
-}
-
-struct FMFileRowRange<'a> {
-    file_name: &'a str,
-    first_row_id: u64,
-    row_count: u64,
-}
-
-fn validate_fm_file_sets(entries_by_field: &HashMap<i32, Vec<GlobalIndexEntry>>) -> Result<()> {
-    for (field_id, entries) in entries_by_field {
-        let mut groups: HashMap<(i64, i64), Vec<FMFileRowRange<'_>>> = HashMap::new();
-        for entry in entries {
-            let GlobalIndexEntryMeta::FM {
-                first_row_id,
-                row_count,
-                ..
-            } = &entry.meta
-            else {
-                continue;
-            };
-            groups
-                .entry((entry.row_range_start, entry.row_range_end))
-                .or_default()
-                .push(FMFileRowRange {
-                    file_name: &entry.file_name,
-                    first_row_id: *first_row_id,
-                    row_count: *row_count,
-                });
-        }
-
-        for ((range_start, range_end), mut files) in groups {
-            let expected_row_count = range_end
-                .checked_sub(range_start)
-                .and_then(|count| count.checked_add(1))
-                .and_then(|count| u64::try_from(count).ok())
-                .ok_or_else(|| Error::DataInvalid {
-                    message: format!(
-                        "Invalid FM global index source row range [{range_start}, {range_end}] for field {field_id}"
-                    ),
-                    source: None,
-                })?;
-            files.sort_unstable_by_key(|file| file.first_row_id);
-            let mut expected_first_row_id = 0u64;
-            for file in files {
-                let FMFileRowRange {
-                    file_name,
-                    first_row_id,
-                    row_count,
-                } = file;
-                if row_count == 0 || first_row_id != expected_first_row_id {
-                    return Err(Error::DataInvalid {
-                        message: format!(
-                            "FM global index files do not exactly cover source row range [{range_start}, {range_end}] for field {field_id}: expected relative row {expected_first_row_id}, file '{file_name}' starts at {first_row_id}"
-                        ),
-                        source: None,
-                    });
-                }
-                expected_first_row_id =
-                    first_row_id
-                        .checked_add(row_count)
-                        .ok_or_else(|| Error::DataInvalid {
-                            message: format!(
-                                "FM global index row range overflows for file '{file_name}'"
-                            ),
-                            source: None,
-                        })?;
-                if expected_first_row_id > expected_row_count {
-                    return Err(Error::DataInvalid {
-                        message: format!(
-                            "FM global index file '{file_name}' extends beyond source row range [{range_start}, {range_end}]"
-                        ),
-                        source: None,
-                    });
-                }
-            }
-            if expected_first_row_id != expected_row_count {
-                return Err(Error::DataInvalid {
-                    message: format!(
-                        "FM global index files cover {expected_first_row_id} rows, expected {expected_row_count} for source row range [{range_start}, {range_end}] and field {field_id}"
-                    ),
-                    source: None,
-                });
-            }
-        }
-    }
-    Ok(())
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-enum GlobalIndexFileKind {
-    BTree,
-    Bitmap,
-    Multivalue,
-    FM,
-}
-
-fn is_floating_point(data_type: &DataType) -> bool {
-    matches!(data_type, DataType::Float(_) | DataType::Double(_))
-}
-
-fn bitmap_meta_may_match(
-    meta: &BTreeIndexMeta,
-    op: PredicateOperator,
-    data_type: &DataType,
-    serialized_literals: &[Vec<u8>],
-    cmp: &dyn Fn(&[u8], &[u8]) -> Ordering,
-) -> bool {
-    if is_floating_point(data_type) && is_bitmap_floating_residual_sensitive_op(op) {
-        !meta.only_nulls()
-    } else {
-        meta.may_match(op, serialized_literals, cmp)
-    }
-}
-
-fn bitmap_meta_may_match_between(
-    meta: &BTreeIndexMeta,
-    data_type: &DataType,
-    from_key: &[u8],
-    to_key: &[u8],
-    cmp: &dyn Fn(&[u8], &[u8]) -> Ordering,
-) -> bool {
-    if is_floating_point(data_type)
-        && is_bitmap_floating_residual_sensitive_op(PredicateOperator::Between)
-    {
-        !meta.only_nulls()
-    } else {
-        meta.may_match_between(from_key, to_key, cmp)
-    }
-}
-
-fn multivalue_meta_may_match(
-    meta: &BTreeIndexMeta,
-    op: PredicateOperator,
-    serialized_literals: &[Vec<u8>],
-    cmp: &dyn Fn(&[u8], &[u8]) -> Ordering,
-) -> bool {
-    match op {
-        PredicateOperator::ArrayContains => {
-            meta.may_match(PredicateOperator::Eq, serialized_literals, cmp)
-        }
-        PredicateOperator::ArraysOverlap => {
-            meta.may_match(PredicateOperator::In, serialized_literals, cmp)
-        }
-        PredicateOperator::ArrayContainsAll => serialized_literals.iter().all(|literal| {
-            meta.may_match(PredicateOperator::Eq, std::slice::from_ref(literal), cmp)
-        }),
-        _ => false,
-    }
-}
-
-impl GlobalIndexFileKind {
-    fn name(self) -> &'static str {
-        match self {
-            Self::BTree => "BTree",
-            Self::Bitmap => "bitmap",
-            Self::Multivalue => "multivalue",
-            Self::FM => "FM",
-        }
-    }
-}
-
-enum OpenedGlobalIndexReader {
-    BTree(BTreeIndexReader<BoxedCmp>),
-    Bitmap(BitmapGlobalIndexReader),
-    FM(FMGlobalIndexReader),
-}
-
-#[derive(Clone, Copy, Default)]
-struct FallbackScanPlan {
-    selected_btree: usize,
-    selected_bitmap: usize,
-    allow_btree: bool,
-    allow_bitmap: bool,
-}
-
-struct EntryQueryPlan {
-    entry_idx: usize,
-    between_matches: bool,
-    between_evaluated: bool,
-    matching_predicates: Vec<usize>,
-}
-
-struct EntryQueryResult {
-    bitmap: Option<RoaringTreemap>,
-    declined: bool,
-}
-
-impl FallbackScanPlan {
-    fn allowed(self, kind: GlobalIndexFileKind) -> bool {
-        match kind {
-            GlobalIndexFileKind::BTree => self.allow_btree,
-            GlobalIndexFileKind::Bitmap | GlobalIndexFileKind::Multivalue => self.allow_bitmap,
-            GlobalIndexFileKind::FM => true,
-        }
-    }
-}
-
-impl OpenedGlobalIndexReader {
-    async fn query(
-        &self,
-        op: PredicateOperator,
-        literals: &[Datum],
-        data_type: &DataType,
-    ) -> std::io::Result<Option<RoaringTreemap>> {
-        match self {
-            Self::BTree(reader) => reader.query(op, literals, data_type).await.map(Some),
-            Self::Bitmap(reader) => reader.query(op, literals, data_type).await.map(Some),
-            Self::FM(reader) => match op {
-                PredicateOperator::Contains => {
-                    let literal = literals.first().ok_or_else(|| {
-                        std::io::Error::new(
-                            std::io::ErrorKind::InvalidInput,
-                            "FM contains requires one literal",
-                        )
-                    })?;
-                    reader
-                        .contains(&serialize_bitmap_datum(literal, data_type))
-                        .await
-                }
-                PredicateOperator::IsNull => reader.is_null().await.map(Some),
-                PredicateOperator::IsNotNull => reader.is_not_null().await.map(Some),
-                _ => Ok(None),
-            },
-        }
-    }
-
-    async fn range_query(
-        &self,
-        from: &[u8],
-        to: &[u8],
-        data_type: &DataType,
-        from_inclusive: bool,
-        to_inclusive: bool,
-    ) -> std::io::Result<RoaringTreemap> {
-        match self {
-            Self::BTree(reader) => {
-                reader
-                    .range_query(from, to, from_inclusive, to_inclusive)
-                    .await
-            }
-            Self::Bitmap(reader) => {
-                reader
-                    .range_query(from, to, data_type, from_inclusive, to_inclusive)
-                    .await
-            }
-            Self::FM(_) => Err(std::io::Error::new(
-                std::io::ErrorKind::InvalidInput,
-                "FM index does not support ordered range queries",
-            )),
-        }
-    }
-}
-
 impl GlobalIndexScanner {
     /// Create a scanner from index manifest entries.
     /// Returns `Ok(None)` if there are no global index entries.
@@ -620,1359 +315,45 @@ impl GlobalIndexScanner {
             query_io_probe: None,
         }))
     }
-
-    /// Evaluate a predicate against the global indexes and return matching row ranges.
-    /// Returns `None` if the predicate cannot be evaluated by the global index.
-    fn evaluate<'a>(&'a self, predicate: &'a Predicate) -> EvaluateFuture<'a> {
-        Box::pin(async move {
-            match predicate {
-                Predicate::Leaf {
-                    column,
-                    op,
-                    literals,
-                    data_type,
-                    ..
-                } => {
-                    if !is_sorted_global_index_supported_op(*op) {
-                        return Ok(None);
-                    }
-                    let field_id = self.find_field_id_by_name(column)?;
-                    let field_id = match field_id {
-                        Some(id) => id,
-                        None => return Ok(None),
-                    };
-                    let entries = match self.entries_for_field(field_id) {
-                        Some(e) => e,
-                        None => return Ok(None),
-                    };
-                    if !entries_support_predicate(entries, *op, literals) {
-                        return Ok(None);
-                    }
-                    let predicates = [(*op, literals.as_slice(), data_type)];
-                    let selected_entries = select_entries_for_predicates(entries, &predicates);
-                    self.evaluate_leaf(&selected_entries, &predicates)
-                        .await
-                        .map(|result| {
-                            result.map(|(row_ranges, indexed_coverage)| GlobalIndexScanResult {
-                                row_ranges,
-                                evaluated_field_ids: HashSet::from([field_id]),
-                                indexed_coverage,
-                            })
-                        })
-                }
-                Predicate::And(children) => {
-                    // Group leaf predicates by field_id to reuse readers
-                    let mut leaf_groups: std::collections::HashMap<i32, Vec<PredicateTuple<'_>>> =
-                        std::collections::HashMap::new();
-                    let mut non_leaf_children = Vec::new();
-
-                    for child in children {
-                        if let Predicate::Leaf {
-                            column,
-                            op,
-                            literals,
-                            data_type,
-                            ..
-                        } = child
-                        {
-                            if is_sorted_global_index_supported_op(*op) {
-                                if let Some(field_id) = self.find_field_id_by_name(column)? {
-                                    if self.entries_for_field(field_id).is_some_and(|entries| {
-                                        entries_support_predicate(entries, *op, literals)
-                                    }) {
-                                        leaf_groups.entry(field_id).or_default().push((
-                                            *op,
-                                            literals.as_slice(),
-                                            data_type,
-                                        ));
-                                        continue;
-                                    }
-                                }
-                            }
-                        }
-                        non_leaf_children.push(child);
-                    }
-
-                    // Evaluate independent fields concurrently while keeping predicates for the
-                    // same field together so each index file is opened only once.
-                    let mut leaf_futures = Vec::with_capacity(leaf_groups.len());
-                    for (field_id, predicates) in &leaf_groups {
-                        if let Some(entries) = self.entries_for_field(*field_id) {
-                            let field_id = *field_id;
-                            let mut selected_predicates = predicates.clone();
-                            let mut selected_entries =
-                                select_entries_for_predicates(entries, &selected_predicates);
-                            if selected_entries.is_empty() {
-                                for predicate in predicates {
-                                    let candidate_predicates = vec![*predicate];
-                                    let candidate_entries = select_entries_for_predicates(
-                                        entries,
-                                        &candidate_predicates,
-                                    );
-                                    if !candidate_entries.is_empty() {
-                                        selected_predicates = candidate_predicates;
-                                        selected_entries = candidate_entries;
-                                        break;
-                                    }
-                                }
-                            }
-                            leaf_futures.push(async move {
-                                let result = self
-                                    .evaluate_leaf(&selected_entries, &selected_predicates)
-                                    .await?;
-                                Ok((field_id, result))
-                            });
-                        }
-                    }
-                    let leaf_group_count = leaf_futures.len();
-                    let (mut row_ranges, mut indexed_coverage, mut evaluated_field_ids) =
-                        try_fold_bounded(
-                            leaf_futures,
-                            leaf_group_count.max(1),
-                            (None::<Vec<RowRange>>, None::<Vec<RowRange>>, HashSet::new()),
-                            |(row_ranges, indexed_coverage, evaluated_field_ids),
-                             (field_id, result)| {
-                                if let Some((ranges, coverage)) = result {
-                                    *row_ranges = Some(match row_ranges.take() {
-                                        None => ranges,
-                                        Some(existing) => {
-                                            intersect_sorted_ranges(&existing, &ranges)
-                                        }
-                                    });
-                                    *indexed_coverage = Some(match indexed_coverage.take() {
-                                        None => coverage,
-                                        Some(existing) => {
-                                            intersect_sorted_ranges(&existing, &coverage)
-                                        }
-                                    });
-                                    evaluated_field_ids.insert(field_id);
-                                }
-                            },
-                        )
-                        .await?;
-
-                    // Evaluate non-leaf children recursively
-                    for child in non_leaf_children {
-                        if let Some(child_result) = self.evaluate(child).await? {
-                            row_ranges = Some(match row_ranges {
-                                None => child_result.row_ranges,
-                                Some(existing) => {
-                                    intersect_sorted_ranges(&existing, &child_result.row_ranges)
-                                }
-                            });
-                            evaluated_field_ids.extend(child_result.evaluated_field_ids);
-                            indexed_coverage = Some(match indexed_coverage {
-                                None => child_result.indexed_coverage,
-                                Some(existing) => intersect_sorted_ranges(
-                                    &existing,
-                                    &child_result.indexed_coverage,
-                                ),
-                            });
-                        }
-                    }
-
-                    Ok(row_ranges.map(|row_ranges| GlobalIndexScanResult {
-                        row_ranges,
-                        evaluated_field_ids,
-                        indexed_coverage: indexed_coverage.unwrap_or_default(),
-                    }))
-                }
-                Predicate::Or(children) => {
-                    let mut all_ranges: Vec<RowRange> = Vec::new();
-                    let mut evaluated_field_ids = HashSet::new();
-                    let mut indexed_coverage: Option<Vec<RowRange>> = None;
-                    for child in children {
-                        match self.evaluate(child).await? {
-                            Some(child_result) => {
-                                all_ranges.extend(child_result.row_ranges);
-                                evaluated_field_ids.extend(child_result.evaluated_field_ids);
-                                indexed_coverage = Some(match indexed_coverage {
-                                    None => child_result.indexed_coverage,
-                                    Some(existing) => intersect_sorted_ranges(
-                                        &existing,
-                                        &child_result.indexed_coverage,
-                                    ),
-                                });
-                            }
-                            None => return Ok(None),
-                        }
-                    }
-                    let row_ranges = if all_ranges.is_empty() {
-                        Vec::new()
-                    } else {
-                        super::merge_row_ranges(all_ranges)
-                    };
-                    Ok(Some(GlobalIndexScanResult {
-                        row_ranges,
-                        evaluated_field_ids,
-                        indexed_coverage: indexed_coverage.unwrap_or_default(),
-                    }))
-                }
-                _ => Ok(None),
-            }
-        })
-    }
-
-    /// Evaluate multiple predicates against the same set of index entries.
-    /// Opens each file once and evaluates all predicates, intersecting results.
-    /// Detects between patterns (GtEq/Gt + LtEq/Lt) and merges them into a single range query.
-    async fn evaluate_leaf(
-        &self,
-        entries: &[&GlobalIndexEntry],
-        predicates: &[(PredicateOperator, &[Datum], &DataType)],
-    ) -> Result<Option<(Vec<RowRange>, Vec<RowRange>)>> {
-        let normalized_predicates = predicates
-            .iter()
-            .map(|(op, literals, data_type)| {
-                let key_type = if is_multivalue_predicate(*op) {
-                    let DataType::Array(array) = data_type else {
-                        return Err(Error::DataInvalid {
-                            message: format!(
-                                "Array global-index predicate {op} requires an ARRAY field type"
-                            ),
-                            source: None,
-                        });
-                    };
-                    array.element_type()
-                } else {
-                    *data_type
-                };
-                Ok((*op, *literals, key_type))
-            })
-            .collect::<Result<Vec<_>>>()?;
-        let predicates = normalized_predicates.as_slice();
-
-        // Try to detect between pattern and split into (between, remaining)
-        let (between, remaining) = extract_between(predicates);
-
-        let effective_predicates = if between.is_some() {
-            &remaining
-        } else {
-            predicates
-        };
-
-        // Pre-compute comparators and serialized keys for file-level pruning per predicate
-        let pruning_info: Vec<_> = effective_predicates
-            .iter()
-            .map(|(op, literals, data_type)| {
-                let btree_cmp = make_key_comparator(data_type);
-                let btree_serialized = literals
-                    .iter()
-                    .map(|l| serialize_datum(l, data_type))
-                    .collect::<Vec<_>>();
-                let bitmap_cmp = make_bitmap_key_comparator(data_type);
-                let bitmap_serialized = literals
-                    .iter()
-                    .map(|l| serialize_bitmap_datum(l, data_type))
-                    .collect::<Vec<_>>();
-                (
-                    *op,
-                    *data_type,
-                    btree_cmp,
-                    btree_serialized,
-                    bitmap_cmp,
-                    bitmap_serialized,
-                )
-            })
-            .collect();
-
-        let predicate_matches: Vec<Vec<bool>> = pruning_info
-            .iter()
-            .map(
-                |(op, data_type, btree_cmp, btree_serialized, bitmap_cmp, bitmap_serialized)| {
-                    entries
-                        .iter()
-                        .map(|entry| match entry.index_type {
-                            GlobalIndexFileKind::BTree => {
-                                sorted_entry_meta(entry).may_match(*op, btree_serialized, btree_cmp)
-                            }
-                            GlobalIndexFileKind::Bitmap => bitmap_meta_may_match(
-                                sorted_entry_meta(entry),
-                                *op,
-                                data_type,
-                                bitmap_serialized,
-                                bitmap_cmp.as_ref(),
-                            ),
-                            GlobalIndexFileKind::Multivalue => multivalue_meta_may_match(
-                                sorted_entry_meta(entry),
-                                *op,
-                                bitmap_serialized,
-                                bitmap_cmp.as_ref(),
-                            ),
-                            GlobalIndexFileKind::FM => true,
-                        })
-                        .collect()
-                },
-            )
-            .collect();
-        let predicate_fallback_plans: Vec<Option<FallbackScanPlan>> = effective_predicates
-            .iter()
-            .enumerate()
-            .map(|(i, (op, _, _))| {
-                requires_fallback_scan(*op)
-                    .then(|| self.fallback_scan_plan(entries, &predicate_matches[i]))
-            })
-            .collect();
-
-        let between_matches_by_entry: Vec<bool> =
-            match between.as_ref() {
-                Some(b) => {
-                    let btree_cmp = make_key_comparator(b.data_type);
-                    let btree_from = serialize_datum(b.from, b.data_type);
-                    let btree_to = serialize_datum(b.to, b.data_type);
-                    let bitmap_cmp = make_bitmap_key_comparator(b.data_type);
-                    let bitmap_from = serialize_bitmap_datum(b.from, b.data_type);
-                    let bitmap_to = serialize_bitmap_datum(b.to, b.data_type);
-                    entries
-                        .iter()
-                        .map(|entry| match entry.index_type {
-                            GlobalIndexFileKind::BTree => sorted_entry_meta(entry)
-                                .may_match_between(&btree_from, &btree_to, &btree_cmp),
-                            GlobalIndexFileKind::Bitmap => bitmap_meta_may_match_between(
-                                sorted_entry_meta(entry),
-                                b.data_type,
-                                &bitmap_from,
-                                &bitmap_to,
-                                bitmap_cmp.as_ref(),
-                            ),
-                            GlobalIndexFileKind::Multivalue | GlobalIndexFileKind::FM => false,
-                        })
-                        .collect()
-                }
-                None => Vec::new(),
-            };
-        let between_fallback_plan = between
-            .as_ref()
-            .map(|_| self.fallback_scan_plan(entries, &between_matches_by_entry));
-
-        let mut query_plans = Vec::with_capacity(entries.len());
-        for (entry_idx, entry) in entries.iter().enumerate() {
-            // Also check if between range may match
-            let between_matches = between
-                .as_ref()
-                .is_some_and(|_| between_matches_by_entry[entry_idx]);
-            let between_evaluated_for_entry = between_fallback_plan.is_some_and(|plan| {
-                fallback_plan_evaluates_entry(plan, entry.index_type, between_matches)
-            });
-
-            // When a Between conjunct exists but the file does not overlap its
-            // range, the whole AND cannot match — drop the file regardless of
-            // how the remaining predicates evaluate. Without this guard, a file
-            // outside the Between range but matched by some remaining predicate
-            // (e.g. `BETWEEN 10 AND 20 AND id >= 0` on a file [30, 40]) would
-            // be retained because `file_result` is initialized from the
-            // remaining bitmap, silently dropping the Between conjunct.
-            if between_evaluated_for_entry && !between_matches {
-                continue;
-            }
-
-            let mut file_evaluated = between_evaluated_for_entry;
-            let mut file_cannot_match = false;
-            let mut file_has_unsupported_match =
-                between_matches && !between_evaluated_for_entry && between_fallback_plan.is_some();
-            let matching_predicates: Vec<usize> = (0..effective_predicates.len())
-                .filter(|&i| {
-                    let predicate_matches_entry = predicate_matches[i][entry_idx];
-                    let predicate_evaluated_for_entry =
-                        predicate_fallback_plans[i].is_none_or(|plan| {
-                            fallback_plan_evaluates_entry(
-                                plan,
-                                entry.index_type,
-                                predicate_matches_entry,
-                            )
-                        });
-                    if !predicate_evaluated_for_entry {
-                        file_has_unsupported_match |= predicate_matches_entry;
-                        return false;
-                    }
-                    file_evaluated = true;
-                    if !predicate_matches_entry {
-                        file_cannot_match = true;
-                        return false;
-                    }
-                    true
-                })
-                .collect();
-            if file_cannot_match {
-                continue;
-            }
-            if !file_evaluated {
-                if file_has_unsupported_match {
-                    return Ok(None);
-                }
-                continue;
-            }
-
-            query_plans.push(EntryQueryPlan {
-                entry_idx,
-                between_matches,
-                between_evaluated: between_evaluated_for_entry,
-                matching_predicates,
-            });
-        }
-
-        // Complete all pruning and fallback decisions before starting shard I/O.
-        // A later unsupported shard must fall back to the normal scan without an
-        // earlier shard racing it with an I/O or query error.
-        let data_type = between
-            .as_ref()
-            .map(|b| b.data_type)
-            .or_else(|| effective_predicates.first().map(|p| p.2))
-            .unwrap_or(predicates[0].2);
-        let between = between.as_ref();
-        let futures =
-            query_plans.into_iter().map(|plan| async move {
-                let entry = &entries[plan.entry_idx];
-                let _permit = self.query_semaphore.acquire().await.map_err(|error| {
-                    Error::UnexpectedError {
-                        message: "global-index query concurrency budget was closed".to_string(),
-                        source: Some(Box::new(error)),
-                    }
-                })?;
-                #[cfg(test)]
-                let _query_io_probe_guard = match &self.query_io_probe {
-                    Some(probe) => Some(probe.enter().await),
-                    None => None,
-                };
-                let result = self
-                    .query_entry(entry, data_type, between, &plan, effective_predicates)
-                    .await?;
-                Ok((entry.row_range_start, result))
-            });
-        let (all_row_ids, declined) = try_fold_bounded(
-            futures,
-            self.global_index_thread_num,
-            (RoaringTreemap::new(), false),
-            |(all_row_ids, declined), (row_range_start, file_result)| {
-                if file_result.declined {
-                    *declined = true;
-                    return;
-                }
-                if let Some(bitmap) = file_result.bitmap {
-                    for row_id in bitmap.iter() {
-                        all_row_ids.insert(row_id + row_range_start as u64);
-                    }
-                }
-            },
-        )
-        .await?;
-
-        if declined {
-            return Ok(None);
-        }
-
-        let coverage = super::merge_row_ranges(
-            entries
-                .iter()
-                .map(|entry| RowRange::new(entry.row_range_start, entry.row_range_end))
-                .collect(),
-        );
-        Ok(Some((bitmap_to_ranges(&all_row_ids), coverage)))
-    }
-
-    async fn query_entry(
-        &self,
-        entry: &GlobalIndexEntry,
-        data_type: &DataType,
-        between: Option<&BetweenInfo<'_>>,
-        plan: &EntryQueryPlan,
-        effective_predicates: &[(PredicateOperator, &[Datum], &DataType)],
-    ) -> Result<EntryQueryResult> {
-        let mut reader = if (plan.between_matches && plan.between_evaluated)
-            || !plan.matching_predicates.is_empty()
-        {
-            Some(self.open_reader_for_entry(entry, data_type).await?)
-        } else {
-            None
-        };
-        let mut file_result = None;
-
-        if plan.between_matches && plan.between_evaluated {
-            let between = between.expect("evaluated between query is present");
-            let serialize_key = match entry.index_type {
-                GlobalIndexFileKind::BTree => serialize_datum,
-                GlobalIndexFileKind::Bitmap | GlobalIndexFileKind::Multivalue => {
-                    serialize_bitmap_datum
-                }
-                GlobalIndexFileKind::FM => unreachable!("FM range query was rejected in planning"),
-            };
-            let from_key = serialize_key(between.from, between.data_type);
-            let to_key = serialize_key(between.to, between.data_type);
-            let bitmap = reader
-                .as_ref()
-                .expect("reader is opened when between matches")
-                .range_query(
-                    &from_key,
-                    &to_key,
-                    between.data_type,
-                    between.from_inclusive,
-                    between.to_inclusive,
-                )
-                .await
-                .map_err(|error| Self::query_error(entry, error))?;
-            file_result = Some(bitmap);
-        }
-
-        for &idx in &plan.matching_predicates {
-            let (op, literals, data_type) = &effective_predicates[idx];
-            let Some(bitmap) = reader
-                .as_ref()
-                .expect("reader is opened when predicates match")
-                .query(*op, literals, data_type)
-                .await
-                .map_err(|error| Self::query_error(entry, error))?
-            else {
-                return Ok(EntryQueryResult {
-                    bitmap: None,
-                    declined: true,
-                });
-            };
-            file_result = Some(match file_result {
-                None => bitmap,
-                Some(mut existing) => {
-                    existing &= bitmap;
-                    existing
-                }
-            });
-        }
-
-        // Each concurrent task owns its reader. Only return it to the shared
-        // cache after all predicates for this shard have completed.
-        if let Some(OpenedGlobalIndexReader::BTree(reader)) = reader.take() {
-            self.return_reader(entry.file_name.clone(), reader);
-        }
-        Ok(EntryQueryResult {
-            bitmap: file_result,
-            declined: false,
-        })
-    }
-
-    fn query_error(entry: &GlobalIndexEntry, error: std::io::Error) -> Error {
-        Error::DataInvalid {
-            message: format!(
-                "Global index query failed for {} file '{}'",
-                entry.index_type.name(),
-                entry.file_name
-            ),
-            source: Some(Box::new(error)),
-        }
-    }
-
-    /// Get a cached reader or open a new one for the given file.
-    async fn get_or_open_reader(
-        &self,
-        entry: &GlobalIndexEntry,
-        meta: &BTreeIndexMeta,
-        data_type: &DataType,
-    ) -> Result<OpenedGlobalIndexReader> {
-        // Try to take from cache
-        {
-            let mut cache = self.reader_cache.lock().unwrap();
-            if let Some(reader) = cache.remove(&entry.file_name) {
-                return Ok(OpenedGlobalIndexReader::BTree(reader));
-            }
-        }
-
-        // Open new reader
-        let path = format!("{}/{INDEX_DIR}/{}", self.table_path, entry.file_name);
-        let input = self.file_io.new_input(&path)?;
-        let file_size = if entry.file_size > 0 {
-            entry.file_size as u64
-        } else {
-            input.metadata().await?.size
-        };
-        let file_reader = input.reader().await?;
-
-        let cmp = make_key_comparator(data_type);
-        BTreeIndexReader::open(Box::new(file_reader), file_size, meta, cmp)
-            .await
-            .map(OpenedGlobalIndexReader::BTree)
-            .map_err(|e| crate::Error::DataInvalid {
-                message: format!("Failed to open BTree index file: {}", entry.file_name),
-                source: Some(Box::new(e)),
-            })
-    }
-
-    async fn open_reader_for_entry(
-        &self,
-        entry: &GlobalIndexEntry,
-        data_type: &DataType,
-    ) -> Result<OpenedGlobalIndexReader> {
-        match entry.index_type {
-            GlobalIndexFileKind::BTree => {
-                self.get_or_open_reader(entry, sorted_entry_meta(entry), data_type)
-                    .await
-            }
-            GlobalIndexFileKind::Bitmap => self
-                .open_bitmap_reader(entry)
-                .await
-                .map(OpenedGlobalIndexReader::Bitmap)
-                .map_err(|e| crate::Error::DataInvalid {
-                    message: format!(
-                        "Failed to open bitmap global index file: {}",
-                        entry.file_name
-                    ),
-                    source: Some(Box::new(e)),
-                }),
-            GlobalIndexFileKind::Multivalue => self
-                .open_bitmap_reader(entry)
-                .await
-                .map(OpenedGlobalIndexReader::Bitmap)
-                .map_err(|e| crate::Error::DataInvalid {
-                    message: format!(
-                        "Failed to open multivalue global index file: {}",
-                        entry.file_name
-                    ),
-                    source: Some(Box::new(e)),
-                }),
-            GlobalIndexFileKind::FM => self
-                .open_fm_reader(entry)
-                .await
-                .map(OpenedGlobalIndexReader::FM)
-                .map_err(|e| crate::Error::DataInvalid {
-                    message: format!("Failed to open FM global index file: {}", entry.file_name),
-                    source: Some(Box::new(e)),
-                }),
-        }
-    }
-
-    async fn open_fm_reader(
-        &self,
-        entry: &GlobalIndexEntry,
-    ) -> std::io::Result<FMGlobalIndexReader> {
-        let GlobalIndexEntryMeta::FM {
-            bytes: manifest_meta,
-            ..
-        } = &entry.meta
-        else {
-            return Err(std::io::Error::new(
-                std::io::ErrorKind::InvalidData,
-                "FM entry has non-FM manifest metadata",
-            ));
-        };
-        let path = format!("{}/{INDEX_DIR}/{}", self.table_path, entry.file_name);
-        let input = self
-            .file_io
-            .new_input(&path)
-            .map_err(|e| std::io::Error::other(e.to_string()))?;
-        let file_size = if entry.file_size > 0 {
-            entry.file_size as u64
-        } else {
-            input
-                .metadata()
-                .await
-                .map_err(|e| std::io::Error::other(e.to_string()))?
-                .size
-        };
-        let file_reader = input
-            .reader()
-            .await
-            .map_err(|e| std::io::Error::other(e.to_string()))?;
-        FMGlobalIndexReader::open_with_context(
-            Box::new(file_reader),
-            file_size,
-            manifest_meta,
-            self.fm_read_options,
-            Arc::clone(&self.fm_read_context),
-            entry.file_name.clone(),
-        )
-        .await
-    }
-
-    async fn open_bitmap_reader(
-        &self,
-        entry: &GlobalIndexEntry,
-    ) -> std::io::Result<BitmapGlobalIndexReader> {
-        let path = format!("{}/{INDEX_DIR}/{}", self.table_path, entry.file_name);
-        let input = self
-            .file_io
-            .new_input(&path)
-            .map_err(|e| std::io::Error::other(e.to_string()))?;
-        let file_size = if entry.file_size > 0 {
-            entry.file_size as u64
-        } else {
-            input
-                .metadata()
-                .await
-                .map_err(|e| std::io::Error::other(e.to_string()))?
-                .size
-        };
-        let file_reader = input
-            .reader()
-            .await
-            .map_err(|e| std::io::Error::other(e.to_string()))?;
-        BitmapGlobalIndexReader::open(Box::new(file_reader), file_size).await
-    }
-
-    fn fallback_scan_plan(
-        &self,
-        entries: &[&GlobalIndexEntry],
-        selected: &[bool],
-    ) -> FallbackScanPlan {
-        let mut plan = FallbackScanPlan::default();
-        let mut btree_total = 0i64;
-        let mut bitmap_total = 0i64;
-        let mut btree_valid = true;
-        let mut bitmap_valid = true;
-
-        for (entry, selected) in entries.iter().zip(selected) {
-            if !selected {
-                continue;
-            }
-            match entry.index_type {
-                GlobalIndexFileKind::BTree => {
-                    plan.selected_btree += 1;
-                    btree_valid &= add_file_size(&mut btree_total, entry.file_size);
-                }
-                GlobalIndexFileKind::Bitmap => {
-                    plan.selected_bitmap += 1;
-                    bitmap_valid &= add_file_size(&mut bitmap_total, entry.file_size);
-                }
-                GlobalIndexFileKind::Multivalue => {
-                    plan.selected_bitmap += 1;
-                    bitmap_valid &= add_file_size(&mut bitmap_total, entry.file_size);
-                }
-                GlobalIndexFileKind::FM => {}
-            }
-        }
-
-        plan.allow_btree = plan.selected_btree > 0
-            && btree_valid
-            && self.btree_fallback_scan_max_size > 0
-            && btree_total <= self.btree_fallback_scan_max_size;
-        plan.allow_bitmap = plan.selected_bitmap > 0
-            && bitmap_valid
-            && self.bitmap_fallback_scan_max_size > 0
-            && bitmap_total <= self.bitmap_fallback_scan_max_size;
-        plan
-    }
-
-    /// Return a reader to the cache for future reuse.
-    fn return_reader(&self, file_name: String, reader: BTreeIndexReader<BoxedCmp>) {
-        let mut cache = self.reader_cache.lock().unwrap();
-        cache.insert(file_name, reader);
-    }
-
-    fn find_field_id_by_name(&self, column: &str) -> Result<Option<i32>> {
-        Ok(crate::table::find_field_id_by_name(
-            &self.schema_fields,
-            column,
-        ))
-    }
-
-    fn entries_for_field(&self, field_id: i32) -> Option<&[GlobalIndexEntry]> {
-        self.entries_by_field
-            .iter()
-            .find(|(id, _)| *id == field_id)
-            .map(|(_, entries)| entries.as_slice())
-    }
-
-    /// Return row ranges not covered by global indexes for this predicate.
-    ///
-    /// `full` uses `[0, snapshot.next_row_id - 1]`; `detail` uses actual
-    /// data-file row ranges collected by the scan. The caller unions these
-    /// ranges with indexed matches, and the normal read filter evaluates the
-    /// predicate on the raw rows.
-    #[cfg(test)]
-    fn unindexed_ranges(
-        &self,
-        predicate: &Predicate,
-        search_mode: GlobalIndexSearchMode,
-        next_row_id: Option<i64>,
-        data_ranges: &[RowRange],
-    ) -> Result<Vec<RowRange>> {
-        let field_ids = self.collect_field_ids(predicate)?;
-        Ok(self.unindexed_ranges_for_field_ids(&field_ids, search_mode, next_row_id, data_ranges))
-    }
-
-    #[cfg(test)]
-    fn unindexed_ranges_for_field_ids(
-        &self,
-        field_ids: &HashSet<i32>,
-        search_mode: GlobalIndexSearchMode,
-        next_row_id: Option<i64>,
-        data_ranges: &[RowRange],
-    ) -> Vec<RowRange> {
-        unindexed_ranges_for_coverage(
-            &self.coverage_by_field,
-            field_ids,
-            search_mode,
-            next_row_id,
-            data_ranges,
-        )
-    }
-
-    #[cfg(test)]
-    fn collect_field_ids(&self, predicate: &Predicate) -> Result<HashSet<i32>> {
-        let mut field_ids = HashSet::new();
-        self.collect_field_ids_inner(predicate, &mut field_ids)?;
-        Ok(field_ids)
-    }
-
-    #[cfg(test)]
-    fn collect_field_ids_inner(
-        &self,
-        predicate: &Predicate,
-        field_ids: &mut HashSet<i32>,
-    ) -> Result<()> {
-        match predicate {
-            Predicate::Leaf { column, .. } => {
-                if let Some(field_id) = self.find_field_id_by_name(column)? {
-                    field_ids.insert(field_id);
-                }
-            }
-            Predicate::And(children) | Predicate::Or(children) => {
-                for child in children {
-                    self.collect_field_ids_inner(child, field_ids)?;
-                }
-            }
-            Predicate::Not(inner) => self.collect_field_ids_inner(inner, field_ids)?,
-            Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
-        }
-        Ok(())
-    }
-}
-
-/// Whether the sorted global index can evaluate this operator directly.
-/// Operators that fall outside this set bypass the index and are evaluated
-/// later in the read pipeline (stats prune + parquet row filter).
-fn is_sorted_global_index_supported_op(op: PredicateOperator) -> bool {
-    matches!(
-        op,
-        PredicateOperator::Eq
-            | PredicateOperator::NotEq
-            | PredicateOperator::Lt
-            | PredicateOperator::LtEq
-            | PredicateOperator::Gt
-            | PredicateOperator::GtEq
-            | PredicateOperator::In
-            | PredicateOperator::NotIn
-            | PredicateOperator::IsNull
-            | PredicateOperator::IsNotNull
-            | PredicateOperator::Between
-            | PredicateOperator::NotBetween
-            | PredicateOperator::StartsWith
-            | PredicateOperator::EndsWith
-            | PredicateOperator::Contains
-            | PredicateOperator::Like
-            | PredicateOperator::ArrayContains
-            | PredicateOperator::ArraysOverlap
-            | PredicateOperator::ArrayContainsAll
-    )
-}
-
-fn is_multivalue_predicate(op: PredicateOperator) -> bool {
-    matches!(
-        op,
-        PredicateOperator::ArrayContains
-            | PredicateOperator::ArraysOverlap
-            | PredicateOperator::ArrayContainsAll
-    )
 }
 
-fn entry_supports_predicate(
-    entry: &GlobalIndexEntry,
-    op: PredicateOperator,
-    literals: &[Datum],
-) -> bool {
-    match entry.index_type {
-        GlobalIndexFileKind::Multivalue => {
-            is_multivalue_predicate(op)
-                && !(matches!(op, PredicateOperator::ArrayContainsAll) && literals.is_empty())
-        }
-        GlobalIndexFileKind::FM => {
-            matches!(op, PredicateOperator::IsNull | PredicateOperator::IsNotNull)
-                || (op == PredicateOperator::Contains && literals.len() == 1)
-        }
-        GlobalIndexFileKind::BTree | GlobalIndexFileKind::Bitmap => !is_multivalue_predicate(op),
-    }
+/// Create a GlobalIndexScanner and evaluate predicates, returning row ranges.
+/// This is the main entry point for the table scan integration.
+///
+/// Returns `None` if global index is not available or predicates can't be evaluated.
+pub(crate) struct GlobalIndexEvaluation<'a> {
+    pub(crate) file_io: &'a FileIO,
+    pub(crate) table_path: &'a str,
+    pub(crate) index_entries: &'a [IndexManifestEntry],
+    pub(crate) predicates: &'a [Predicate],
+    pub(crate) schema_fields: &'a [DataField],
+    pub(crate) search_mode: GlobalIndexSearchMode,
+    pub(crate) global_index_thread_num: usize,
+    pub(crate) btree_fallback_scan_max_size: i64,
+    pub(crate) bitmap_fallback_scan_max_size: i64,
+    pub(crate) fm_read_options: FMReadOptions,
+    pub(crate) next_row_id: Option<i64>,
+    pub(crate) data_ranges: &'a [RowRange],
 }
 
-fn entries_support_predicate(
-    entries: &[GlobalIndexEntry],
-    op: PredicateOperator,
-    literals: &[Datum],
-) -> bool {
-    entries
-        .iter()
-        .any(|entry| entry_supports_predicate(entry, op, literals))
-}
+pub(crate) async fn evaluate_global_index(
+    evaluation: GlobalIndexEvaluation<'_>,
+) -> Result<Option<Vec<RowRange>>> {
+    let scanner = match GlobalIndexScanner::create_with_fm_options(
+        evaluation.file_io,
+        evaluation.table_path,
+        evaluation.global_index_thread_num,
+        evaluation.btree_fallback_scan_max_size,
+        evaluation.bitmap_fallback_scan_max_size,
+        evaluation.index_entries,
+        evaluation.schema_fields,
+        evaluation.fm_read_options,
+    )? {
+        Some(s) => s,
+        None => return Ok(None),
+    };
 
-fn select_entries_for_predicates<'a>(
-    entries: &'a [GlobalIndexEntry],
-    predicates: &[(PredicateOperator, &[Datum], &DataType)],
-) -> Vec<&'a GlobalIndexEntry> {
-    let compatible = entries
-        .iter()
-        .filter(|entry| {
-            predicates
-                .iter()
-                .all(|(op, literals, _)| entry_supports_predicate(entry, *op, literals))
-        })
-        .collect::<Vec<_>>();
-
-    if predicates
-        .iter()
-        .any(|(op, _, _)| *op == PredicateOperator::Contains)
-        && compatible
-            .iter()
-            .any(|entry| entry.index_type == GlobalIndexFileKind::FM)
-    {
-        compatible
-            .into_iter()
-            .filter(|entry| entry.index_type == GlobalIndexFileKind::FM)
-            .collect()
-    } else {
-        compatible
-    }
-}
-
-fn requires_fallback_scan(op: PredicateOperator) -> bool {
-    matches!(
-        op,
-        PredicateOperator::Lt
-            | PredicateOperator::LtEq
-            | PredicateOperator::Gt
-            | PredicateOperator::GtEq
-            | PredicateOperator::Between
-            | PredicateOperator::NotBetween
-            | PredicateOperator::EndsWith
-            | PredicateOperator::Contains
-            | PredicateOperator::Like
-    )
-}
-
-fn fallback_plan_evaluates_entry(
-    plan: FallbackScanPlan,
-    kind: GlobalIndexFileKind,
-    selected: bool,
-) -> bool {
-    !selected || plan.allowed(kind)
-}
-
-fn add_file_size(total: &mut i64, file_size: i64) -> bool {
-    if file_size < 0 {
-        return false;
-    }
-    match total.checked_add(file_size) {
-        Some(next) => {
-            *total = next;
-            true
-        }
-        None => false,
-    }
-}
-
-/// Convert a RoaringTreemap to merged RowRanges (already sorted and deduplicated).
-fn bitmap_to_ranges(bitmap: &RoaringTreemap) -> Vec<RowRange> {
-    if bitmap.is_empty() {
-        return Vec::new();
-    }
-    let mut ranges = Vec::new();
-    let mut iter = bitmap.iter();
-    let first = iter.next().unwrap();
-    let mut start = first as i64;
-    let mut end = start;
-
-    for id in iter {
-        let id = id as i64;
-        if id == end + 1 {
-            end = id;
-        } else {
-            ranges.push(RowRange::new(start, end));
-            start = id;
-            end = id;
-        }
-    }
-    ranges.push(RowRange::new(start, end));
-    ranges
-}
-
-/// Intersect two sorted range lists using RowRangeIndex for efficient binary search.
-fn intersect_sorted_ranges(a: &[RowRange], b: &[RowRange]) -> Vec<RowRange> {
-    let idx = RowRangeIndex::create(a.to_vec());
-    let mut result = Vec::new();
-    for r in b {
-        result.extend(idx.intersected_ranges(r.from(), r.to()));
-    }
-    result
-}
-
-fn data_ranges_for_search_mode(
-    search_mode: GlobalIndexSearchMode,
-    next_row_id: Option<i64>,
-    data_ranges: &[RowRange],
-) -> Option<Vec<RowRange>> {
-    match search_mode {
-        GlobalIndexSearchMode::Fast => None,
-        GlobalIndexSearchMode::Full => match next_row_id {
-            Some(next_row_id) if next_row_id > 0 => Some(vec![RowRange::new(0, next_row_id - 1)]),
-            _ => None,
-        },
-        GlobalIndexSearchMode::Detail => {
-            if data_ranges.is_empty() {
-                None
-            } else {
-                Some(data_ranges.to_vec())
-            }
-        }
-    }
-}
-
-fn indexed_ranges_from_coverage(
-    coverage_by_field: &HashMap<i32, Vec<RowRange>>,
-    field_ids: &HashSet<i32>,
-) -> Vec<RowRange> {
-    let mut ranges: Option<Vec<RowRange>> = None;
-    for field_id in field_ids {
-        let Some(field_ranges) = coverage_by_field.get(field_id) else {
-            return Vec::new();
-        };
-        if field_ranges.is_empty() {
-            return Vec::new();
-        }
-        let field_ranges = super::merge_row_ranges(field_ranges.clone());
-        ranges = Some(match ranges {
-            None => field_ranges,
-            Some(existing) => intersect_sorted_ranges(&existing, &field_ranges),
-        });
-    }
-    ranges.map(super::merge_row_ranges).unwrap_or_default()
-}
-
-fn unindexed_ranges_for_coverage(
-    coverage_by_field: &HashMap<i32, Vec<RowRange>>,
-    field_ids: &HashSet<i32>,
-    search_mode: GlobalIndexSearchMode,
-    next_row_id: Option<i64>,
-    data_ranges: &[RowRange],
-) -> Vec<RowRange> {
-    let Some(data_ranges) = data_ranges_for_search_mode(search_mode, next_row_id, data_ranges)
-    else {
-        return Vec::new();
-    };
-    let indexed_ranges = indexed_ranges_from_coverage(coverage_by_field, field_ids);
-    super::source::exclude_row_ranges(&data_ranges, &indexed_ranges)
-}
-
-fn unindexed_ranges_for_indexed_coverage(
-    indexed_ranges: &[RowRange],
-    search_mode: GlobalIndexSearchMode,
-    next_row_id: Option<i64>,
-    data_ranges: &[RowRange],
-) -> Vec<RowRange> {
-    let Some(data_ranges) = data_ranges_for_search_mode(search_mode, next_row_id, data_ranges)
-    else {
-        return Vec::new();
-    };
-    super::source::exclude_row_ranges(
-        &data_ranges,
-        &super::merge_row_ranges(indexed_ranges.to_vec()),
-    )
-}
-
-/// Compute row ranges not covered by a family of global index files.
-///
-/// This mirrors Java `GlobalIndexCoverage`: `full` compares index coverage
-/// against `[0, snapshot.next_row_id - 1]`, while `detail` compares against
-/// exact data-file row ranges supplied by the caller.
-pub(crate) fn unindexed_ranges_for_global_index_entries(
-    index_entries: &[IndexManifestEntry],
-    field_ids: &HashSet<i32>,
-    search_mode: GlobalIndexSearchMode,
-    next_row_id: Option<i64>,
-    data_ranges: &[RowRange],
-    index_file_filter: impl Fn(&IndexFileMeta) -> bool,
-) -> Vec<RowRange> {
-    let mut coverage_by_field: HashMap<i32, Vec<RowRange>> = HashMap::new();
-    for entry in index_entries {
-        if entry.kind != FileKind::Add || !index_file_filter(&entry.index_file) {
-            continue;
-        }
-        let Some(global_meta) = entry.index_file.global_index_meta.as_ref() else {
-            continue;
-        };
-        let row_range = RowRange::new(global_meta.row_range_start, global_meta.row_range_end);
-        coverage_by_field
-            .entry(global_meta.index_field_id)
-            .or_default()
-            .push(row_range.clone());
-        if let Some(extra_field_ids) = global_meta.extra_field_ids.as_ref() {
-            for extra_field_id in extra_field_ids {
-                coverage_by_field
-                    .entry(*extra_field_id)
-                    .or_default()
-                    .push(row_range.clone());
-            }
-        }
-    }
-    unindexed_ranges_for_coverage(
-        &coverage_by_field,
-        field_ids,
-        search_mode,
-        next_row_id,
-        data_ranges,
-    )
-}
-
-/// Resolve live deletion-vector index entries into global row-id ranges.
-///
-/// Data-evolution DV entries are keyed by the normal anchor data file. The DV
-/// bitmap positions are local to that anchor file's `first_row_id`, so this
-/// helper joins index metadata with live data-file metadata before converting
-/// deleted positions to global row IDs.
-pub(crate) async fn deleted_row_ranges_for_data_evolution_dvs(
-    table: &Table,
-    index_entries: &[IndexManifestEntry],
-) -> Result<Vec<RowRange>> {
-    if !index_entries.iter().any(|entry| {
-        entry.kind == FileKind::Add && entry.index_file.index_type == DELETION_VECTORS_INDEX_TYPE
-    }) {
-        return Ok(Vec::new());
-    }
-
-    let plan = table
-        .new_read_builder()
-        .new_scan()
-        .with_scan_all_files()
-        .plan()
-        .await?;
-
-    let mut first_row_ids: HashMap<(Vec<u8>, i32, String), i64> = HashMap::new();
-    for split in plan.splits() {
-        let partition = split.partition().to_serialized_bytes();
-        let bucket = split.bucket();
-        for file in split.data_files() {
-            if let Some(first_row_id) = file.first_row_id {
-                first_row_ids.insert(
-                    (partition.clone(), bucket, file.file_name.clone()),
-                    first_row_id,
-                );
-            }
-        }
-    }
-
-    let mut ranges = Vec::new();
-    let table_path = table.location().trim_end_matches('/');
-    for entry in index_entries {
-        if entry.kind != FileKind::Add || entry.index_file.index_type != DELETION_VECTORS_INDEX_TYPE
-        {
-            continue;
-        }
-        let Some(dv_ranges) = entry.index_file.deletion_vectors_ranges.as_ref() else {
-            continue;
-        };
-        let index_path = format!("{table_path}/{INDEX_DIR}/{}", entry.index_file.file_name);
-        for (data_file_name, meta) in dv_ranges {
-            let key = (
-                entry.partition.clone(),
-                entry.bucket,
-                data_file_name.clone(),
-            );
-            let first_row_id = first_row_ids.get(&key).copied().ok_or_else(|| {
-                crate::Error::DataInvalid {
-                    message: format!(
-                        "Deletion vector references data file '{}' but no live row-tracked file was found",
-                        data_file_name
-                    ),
-                    source: None,
-                }
-            })?;
-            let deletion_file = DeletionFile::new(
-                index_path.clone(),
-                meta.offset as i64,
-                meta.length as i64,
-                meta.cardinality,
-            );
-            let deletion_vector =
-                DeletionVectorFactory::read(table.file_io(), &deletion_file).await?;
-            for deleted in deletion_vector.iter() {
-                let deleted = i64::try_from(deleted).map_err(|_| crate::Error::DataInvalid {
-                    message: format!(
-                        "Deleted position {deleted} for data file '{}' exceeds i64::MAX",
-                        data_file_name
-                    ),
-                    source: None,
-                })?;
-                let row_id =
-                    first_row_id
-                        .checked_add(deleted)
-                        .ok_or_else(|| crate::Error::DataInvalid {
-                            message: format!(
-                                "Deleted row id overflows i64 for data file '{}'",
-                                data_file_name
-                            ),
-                            source: None,
-                        })?;
-                ranges.push(RowRange::new(row_id, row_id));
-            }
-        }
-    }
-
-    Ok(super::merge_row_ranges(ranges))
-}
-
-/// Index for row ranges. Stores sorted, non-overlapping ranges and supports
-/// efficient intersection queries via binary search.
-///
-/// Reference: [org.apache.paimon.utils.RowRangeIndex](https://github.com/apache/paimon/blob/master/paimon-common/src/main/java/org/apache/paimon/utils/RowRangeIndex.java)
-#[derive(Debug, Clone)]
-pub(crate) struct RowRangeIndex {
-    ranges: Vec<RowRange>,
-    starts: Vec<i64>,
-    ends: Vec<i64>,
-}
-
-impl RowRangeIndex {
-    /// Create a new `RowRangeIndex` from a list of ranges.
-    /// Ranges are sorted and merged to eliminate overlaps.
-    pub fn create(ranges: Vec<RowRange>) -> Self {
-        let ranges = super::merge_row_ranges(ranges);
-        let starts: Vec<i64> = ranges.iter().map(|r| r.from()).collect();
-        let ends: Vec<i64> = ranges.iter().map(|r| r.to()).collect();
-        Self {
-            ranges,
-            starts,
-            ends,
-        }
-    }
-
-    /// Returns the underlying ranges.
-    #[cfg(test)]
-    pub fn ranges(&self) -> &[RowRange] {
-        &self.ranges
-    }
-
-    /// Returns true if the index has any range that intersects `[start, end]`.
-    pub fn intersects(&self, start: i64, end: i64) -> bool {
-        let candidate = lower_bound(&self.ends, start);
-        candidate < self.starts.len() && self.starts[candidate] <= end
-    }
-
-    /// Counts rows in this index that intersect `[start, end]`.
-    pub fn intersection_row_count(&self, start: i64, end: i64) -> usize {
-        if start > end {
-            return 0;
-        }
-        self.intersected_ranges(start, end)
-            .into_iter()
-            .fold(0usize, |total, range| {
-                let len = range.to().saturating_sub(range.from()).saturating_add(1);
-                total.saturating_add(usize::try_from(len).unwrap_or(usize::MAX))
-            })
-    }
-
-    /// Returns the sub-ranges of this index that intersect `[start, end]`,
-    /// clipped to the intersection boundaries.
-    pub fn intersected_ranges(&self, start: i64, end: i64) -> Vec<RowRange> {
-        let left = lower_bound(&self.ends, start);
-        if left >= self.ranges.len() || self.starts[left] > end {
-            return Vec::new();
-        }
-
-        let mut right = lower_bound(&self.ends, end);
-        if right >= self.ranges.len() {
-            right = self.ranges.len() - 1;
-        }
-
-        let mut result = Vec::new();
-
-        // First range: clip from the left
-        let first = &self.ranges[left];
-        result.push(RowRange::new(start.max(first.from()), end.min(first.to())));
-
-        // Middle ranges: fully included
-        if right > left + 1 {
-            for r in &self.ranges[left + 1..right] {
-                result.push(r.clone());
-            }
-        }
-
-        // Last range (if different from first): clip from the right
-        if right != left {
-            let last = &self.ranges[right];
-            if last.from() <= end {
-                result.push(RowRange::new(start.max(last.from()), end.min(last.to())));
-            }
-        }
-
-        result
-    }
-}
-
-pub(crate) fn search_limit_with_deleted_rows(
-    limit: usize,
-    row_range_start: i64,
-    row_range_end: i64,
-    deleted_rows: Option<&RowRangeIndex>,
-) -> usize {
-    let Some(range_len) = row_range_end
-        .checked_sub(row_range_start)
-        .and_then(|len| len.checked_add(1))
-        .and_then(|len| usize::try_from(len).ok())
-    else {
-        return limit;
-    };
-
-    let deleted_count = deleted_rows
-        .map(|index| index.intersection_row_count(row_range_start, row_range_end))
-        .unwrap_or(0)
-        .min(range_len);
-    limit.saturating_add(deleted_count).min(range_len)
-}
-
-/// Binary search: find the first index where `sorted[index] >= target`.
-fn lower_bound(sorted: &[i64], target: i64) -> usize {
-    let mut left = 0;
-    let mut right = sorted.len();
-    while left < right {
-        let mid = left + (right - left) / 2;
-        if sorted[mid] < target {
-            left = mid + 1;
-        } else {
-            right = mid;
-        }
-    }
-    left
-}
-
-/// Create a GlobalIndexScanner and evaluate predicates, returning row ranges.
-/// This is the main entry point for the table scan integration.
-///
-/// Returns `None` if global index is not available or predicates can't be evaluated.
-pub(crate) struct GlobalIndexEvaluation<'a> {
-    pub(crate) file_io: &'a FileIO,
-    pub(crate) table_path: &'a str,
-    pub(crate) index_entries: &'a [IndexManifestEntry],
-    pub(crate) predicates: &'a [Predicate],
-    pub(crate) schema_fields: &'a [DataField],
-    pub(crate) search_mode: GlobalIndexSearchMode,
-    pub(crate) global_index_thread_num: usize,
-    pub(crate) btree_fallback_scan_max_size: i64,
-    pub(crate) bitmap_fallback_scan_max_size: i64,
-    pub(crate) fm_read_options: FMReadOptions,
-    pub(crate) next_row_id: Option<i64>,
-    pub(crate) data_ranges: &'a [RowRange],
-}
-
-pub(crate) async fn evaluate_global_index(
-    evaluation: GlobalIndexEvaluation<'_>,
-) -> Result<Option<Vec<RowRange>>> {
-    let scanner = match GlobalIndexScanner::create_with_fm_options(
-        evaluation.file_io,
-        evaluation.table_path,
-        evaluation.global_index_thread_num,
-        evaluation.btree_fallback_scan_max_size,
-        evaluation.bitmap_fallback_scan_max_size,
-        evaluation.index_entries,
-        evaluation.schema_fields,
-        evaluation.fm_read_options,
-    )? {
-        Some(s) => s,
-        None => return Ok(None),
-    };
-
-    let combined = Predicate::and(evaluation.predicates.to_vec());
+    let combined = Predicate::and(evaluation.predicates.to_vec());
 
     let scan_result = match scanner.evaluate(&combined).await? {
         Some(scan_result) => scan_result,
@@ -1989,2141 +370,4 @@ pub(crate) async fn evaluate_global_index(
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::btree::test_util::VecFileWrite;
-    use crate::btree::{BTreeIndexWriter, BlockCompressionType};
-    use crate::fm_index::{FMGlobalIndexWriter, FMWriteOptions};
-    use crate::table::bitmap_global_index_writer::BitmapGlobalIndexWriter;
-    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
-    use std::sync::Arc;
-
-    #[tokio::test]
-    async fn test_try_fold_bounded_respects_concurrency_limit() {
-        for limit in [1, 3] {
-            let active = Arc::new(AtomicUsize::new(0));
-            let peak = Arc::new(AtomicUsize::new(0));
-            let futures = (0..9usize).map(|value| {
-                let active = Arc::clone(&active);
-                let peak = Arc::clone(&peak);
-                async move {
-                    let current = active.fetch_add(1, AtomicOrdering::SeqCst) + 1;
-                    peak.fetch_max(current, AtomicOrdering::SeqCst);
-                    tokio::task::yield_now().await;
-                    active.fetch_sub(1, AtomicOrdering::SeqCst);
-                    Ok::<_, crate::Error>(value)
-                }
-            });
-
-            let mut values = try_fold_bounded(futures, limit, Vec::new(), |values, value| {
-                values.push(value)
-            })
-            .await
-            .unwrap();
-            values.sort_unstable();
-
-            assert_eq!(values, (0..9).collect::<Vec<_>>());
-            assert_eq!(peak.load(AtomicOrdering::SeqCst), limit);
-        }
-    }
-
-    #[test]
-    fn test_bitmap_to_ranges() {
-        assert_eq!(
-            bitmap_to_ranges(&RoaringTreemap::new()),
-            Vec::<RowRange>::new()
-        );
-
-        let mut bm = RoaringTreemap::new();
-        bm.insert(5);
-        assert_eq!(bitmap_to_ranges(&bm), vec![RowRange::new(5, 5)]);
-
-        let mut bm = RoaringTreemap::new();
-        for id in [1, 2, 3, 5, 6, 10] {
-            bm.insert(id);
-        }
-        assert_eq!(
-            bitmap_to_ranges(&bm),
-            vec![
-                RowRange::new(1, 3),
-                RowRange::new(5, 6),
-                RowRange::new(10, 10),
-            ]
-        );
-    }
-
-    #[test]
-    fn test_intersect_sorted_ranges() {
-        let a = vec![RowRange::new(0, 10), RowRange::new(20, 30)];
-        let b = vec![RowRange::new(5, 25)];
-        let result = intersect_sorted_ranges(&a, &b);
-        assert_eq!(result, vec![RowRange::new(5, 10), RowRange::new(20, 25)]);
-    }
-
-    #[test]
-    fn test_intersect_no_overlap() {
-        let a = vec![RowRange::new(0, 5)];
-        let b = vec![RowRange::new(10, 20)];
-        assert!(intersect_sorted_ranges(&a, &b).is_empty());
-    }
-
-    #[test]
-    fn test_serialize_datum_int() {
-        let key = serialize_datum(&Datum::Int(42), &DataType::Int(crate::spec::IntType::new()));
-        assert_eq!(key, 42i32.to_le_bytes().to_vec());
-    }
-
-    #[test]
-    fn test_serialize_datum_string() {
-        let key = serialize_datum(
-            &Datum::String("hello".to_string()),
-            &DataType::VarChar(crate::spec::VarCharType::new(100).unwrap()),
-        );
-        assert_eq!(key, b"hello".to_vec());
-    }
-
-    fn assert_bitmap_floating_meta_policy(
-        data_type: DataType,
-        min: Datum,
-        max: Datum,
-        outside: Datum,
-        nan: Datum,
-    ) {
-        let cmp = make_bitmap_key_comparator(&data_type);
-        let min_key = serialize_bitmap_datum(&min, &data_type);
-        let max_key = serialize_bitmap_datum(&max, &data_type);
-        let outside_key = serialize_bitmap_datum(&outside, &data_type);
-        let nan_key = serialize_bitmap_datum(&nan, &data_type);
-        let meta = BTreeIndexMeta::new(Some(min_key.clone()), Some(max_key), false);
-
-        assert!(!bitmap_meta_may_match(
-            &meta,
-            PredicateOperator::Eq,
-            &data_type,
-            std::slice::from_ref(&outside_key),
-            cmp.as_ref(),
-        ));
-        assert!(!bitmap_meta_may_match(
-            &meta,
-            PredicateOperator::In,
-            &data_type,
-            std::slice::from_ref(&outside_key),
-            cmp.as_ref(),
-        ));
-        assert!(!bitmap_meta_may_match(
-            &meta,
-            PredicateOperator::IsNull,
-            &data_type,
-            &[],
-            cmp.as_ref(),
-        ));
-        assert!(bitmap_meta_may_match(
-            &meta,
-            PredicateOperator::IsNotNull,
-            &data_type,
-            &[],
-            cmp.as_ref(),
-        ));
-
-        let nan_meta = BTreeIndexMeta::new(Some(min_key), Some(nan_key.clone()), false);
-        assert!(bitmap_meta_may_match(
-            &nan_meta,
-            PredicateOperator::Eq,
-            &data_type,
-            std::slice::from_ref(&nan_key),
-            cmp.as_ref(),
-        ));
-        assert!(bitmap_meta_may_match(
-            &nan_meta,
-            PredicateOperator::In,
-            &data_type,
-            std::slice::from_ref(&nan_key),
-            cmp.as_ref(),
-        ));
-
-        assert!(bitmap_meta_may_match(
-            &meta,
-            PredicateOperator::Gt,
-            &data_type,
-            std::slice::from_ref(&outside_key),
-            cmp.as_ref(),
-        ));
-        assert!(bitmap_meta_may_match_between(
-            &meta,
-            &data_type,
-            &outside_key,
-            &outside_key,
-            cmp.as_ref(),
-        ));
-
-        let only_nulls = BTreeIndexMeta::new(None, None, true);
-        assert!(bitmap_meta_may_match(
-            &only_nulls,
-            PredicateOperator::IsNull,
-            &data_type,
-            &[],
-            cmp.as_ref(),
-        ));
-        assert!(!bitmap_meta_may_match(
-            &only_nulls,
-            PredicateOperator::IsNotNull,
-            &data_type,
-            &[],
-            cmp.as_ref(),
-        ));
-        assert!(!bitmap_meta_may_match(
-            &only_nulls,
-            PredicateOperator::NotEq,
-            &data_type,
-            std::slice::from_ref(&outside_key),
-            cmp.as_ref(),
-        ));
-        assert!(!bitmap_meta_may_match_between(
-            &only_nulls,
-            &data_type,
-            &outside_key,
-            &outside_key,
-            cmp.as_ref(),
-        ));
-    }
-
-    #[test]
-    fn test_bitmap_floating_meta_prunes_equality_and_fails_open_for_ranges() {
-        assert_bitmap_floating_meta_policy(
-            DataType::Float(crate::spec::FloatType::new()),
-            Datum::Float(-1.0),
-            Datum::Float(1.0),
-            Datum::Float(2.0),
-            Datum::Float(f32::NAN),
-        );
-        assert_bitmap_floating_meta_policy(
-            DataType::Double(crate::spec::DoubleType::new()),
-            Datum::Double(-1.0),
-            Datum::Double(1.0),
-            Datum::Double(2.0),
-            Datum::Double(f64::NAN),
-        );
-    }
-
-    #[test]
-    fn test_row_range_index_merges_overlapping() {
-        let idx = RowRangeIndex::create(vec![
-            RowRange::new(0, 5),
-            RowRange::new(3, 10),
-            RowRange::new(20, 30),
-        ]);
-        assert_eq!(idx.ranges().len(), 2);
-        assert_eq!(idx.ranges()[0], RowRange::new(0, 10));
-        assert_eq!(idx.ranges()[1], RowRange::new(20, 30));
-    }
-
-    #[test]
-    fn test_row_range_index_merges_adjacent() {
-        let idx = RowRangeIndex::create(vec![RowRange::new(0, 5), RowRange::new(6, 10)]);
-        assert_eq!(idx.ranges().len(), 1);
-        assert_eq!(idx.ranges()[0], RowRange::new(0, 10));
-    }
-
-    #[test]
-    fn test_row_range_index_intersects() {
-        let idx = RowRangeIndex::create(vec![RowRange::new(10, 20), RowRange::new(30, 40)]);
-        assert!(idx.intersects(15, 25));
-        assert!(idx.intersects(5, 10));
-        assert!(idx.intersects(20, 30));
-        assert!(!idx.intersects(0, 9));
-        assert!(!idx.intersects(21, 29));
-        assert!(!idx.intersects(41, 50));
-    }
-
-    #[test]
-    fn test_row_range_index_intersected_ranges() {
-        let idx = RowRangeIndex::create(vec![
-            RowRange::new(10, 20),
-            RowRange::new(30, 40),
-            RowRange::new(50, 60),
-        ]);
-        let result = idx.intersected_ranges(15, 55);
-        assert_eq!(
-            result,
-            vec![
-                RowRange::new(15, 20),
-                RowRange::new(30, 40),
-                RowRange::new(50, 55),
-            ]
-        );
-    }
-
-    #[test]
-    fn test_row_range_index_intersection_row_count() {
-        let idx = RowRangeIndex::create(vec![
-            RowRange::new(10, 20),
-            RowRange::new(30, 40),
-            RowRange::new(50, 60),
-        ]);
-
-        assert_eq!(idx.intersection_row_count(15, 55), 23);
-        assert_eq!(idx.intersection_row_count(21, 29), 0);
-        assert_eq!(idx.intersection_row_count(55, 15), 0);
-    }
-
-    #[test]
-    fn test_search_limit_with_deleted_rows_expands_and_caps() {
-        let idx = RowRangeIndex::create(vec![RowRange::new(2, 4), RowRange::new(8, 10)]);
-
-        assert_eq!(search_limit_with_deleted_rows(5, 0, 19, Some(&idx)), 11);
-        assert_eq!(search_limit_with_deleted_rows(18, 0, 19, Some(&idx)), 20);
-        assert_eq!(search_limit_with_deleted_rows(5, 0, 19, None), 5);
-    }
-
-    #[test]
-    fn test_row_range_index_empty() {
-        let idx = RowRangeIndex::create(Vec::new());
-        assert!(!idx.intersects(0, 100));
-        assert!(idx.intersected_ranges(0, 100).is_empty());
-    }
-
-    fn le_int_key(v: i32) -> Vec<u8> {
-        v.to_le_bytes().to_vec()
-    }
-
-    /// Set up a temp dir with `index/{file_name}` containing the btree testdata file,
-    /// and return (FileIO, table_path, file_name, _tmp_dir).
-    fn setup_testdata_table(testdata_name: &str) -> (FileIO, String, String, tempfile::TempDir) {
-        let src = format!(
-            "{}/testdata/btree/{testdata_name}",
-            env!("CARGO_MANIFEST_DIR")
-        );
-        let tmp = tempfile::tempdir().unwrap();
-        let index_dir = tmp.path().join("index");
-        std::fs::create_dir_all(&index_dir).unwrap();
-        std::fs::copy(&src, index_dir.join(testdata_name)).unwrap();
-
-        let table_path = format!("file://{}", tmp.path().display());
-        let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
-        (file_io, table_path, testdata_name.to_string(), tmp)
-    }
-
-    type BitmapTestdataTable = (FileIO, String, String, BTreeIndexMeta, tempfile::TempDir);
-
-    fn setup_bitmap_testdata_table(file_name: &str) -> BitmapTestdataTable {
-        let src = format!("{}/testdata/bitmap/{file_name}", env!("CARGO_MANIFEST_DIR"));
-        let meta_src = format!(
-            "{}/testdata/bitmap/{file_name}.meta",
-            env!("CARGO_MANIFEST_DIR")
-        );
-        let tmp = tempfile::tempdir().unwrap();
-        let index_dir = tmp.path().join("index");
-        std::fs::create_dir_all(&index_dir).unwrap();
-        std::fs::copy(&src, index_dir.join(file_name)).unwrap();
-        let meta = BTreeIndexMeta::deserialize(&std::fs::read(meta_src).unwrap()).unwrap();
-
-        let table_path = format!("file://{}", tmp.path().display());
-        let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
-        (file_io, table_path, file_name.to_string(), meta, tmp)
-    }
-
-    fn setup_java_bitmap_testdata_table() -> BitmapTestdataTable {
-        setup_bitmap_testdata_table("bitmap_varchar_java.index")
-    }
-
-    fn make_global_index_entry(
-        file_name: &str,
-        field_id: i32,
-        row_range_start: i64,
-        row_range_end: i64,
-        meta: &BTreeIndexMeta,
-    ) -> crate::spec::IndexManifestEntry {
-        make_global_index_entry_with_type(
-            BTREE_GLOBAL_INDEX_TYPE,
-            file_name,
-            field_id,
-            row_range_start,
-            row_range_end,
-            meta,
-        )
-    }
-
-    fn make_global_index_entry_with_type(
-        index_type: &str,
-        file_name: &str,
-        field_id: i32,
-        row_range_start: i64,
-        row_range_end: i64,
-        meta: &BTreeIndexMeta,
-    ) -> crate::spec::IndexManifestEntry {
-        use crate::spec::{GlobalIndexMeta, IndexFileMeta};
-        IndexManifestEntry {
-            version: 1,
-            kind: FileKind::Add,
-            partition: vec![],
-            bucket: 0,
-            index_file: IndexFileMeta {
-                index_type: index_type.to_string(),
-                file_name: file_name.to_string(),
-                file_size: 0,
-                row_count: 0,
-                deletion_vectors_ranges: None,
-                global_index_meta: Some(GlobalIndexMeta {
-                    row_range_start,
-                    row_range_end,
-                    index_field_id: field_id,
-                    extra_field_ids: None,
-                    source_meta: None,
-                    index_meta: Some(meta.serialize()),
-                }),
-            },
-        }
-    }
-
-    async fn make_fm_index_entry(
-        file_name: &str,
-        field_id: i32,
-        row_range_start: i64,
-        row_range_end: i64,
-        first_row_id: u64,
-        row_count: u64,
-    ) -> IndexManifestEntry {
-        let output = VecFileWrite::new();
-        let mut writer = FMGlobalIndexWriter::new(
-            Box::new(output.clone()),
-            FMWriteOptions {
-                compression: BlockCompressionType::None,
-                ..FMWriteOptions::default()
-            },
-        )
-        .unwrap();
-        for row_id in first_row_id..first_row_id + row_count {
-            writer.write(Some(b"value"), row_id).await.unwrap();
-        }
-        let result = writer.finish().await.unwrap();
-        IndexManifestEntry {
-            version: 1,
-            kind: FileKind::Add,
-            partition: vec![],
-            bucket: 0,
-            index_file: IndexFileMeta {
-                index_type: FM_GLOBAL_INDEX_TYPE.to_string(),
-                file_name: file_name.to_string(),
-                file_size: output.to_vec().len() as i64,
-                row_count: result.row_count as i64,
-                deletion_vectors_ranges: None,
-                global_index_meta: Some(crate::spec::GlobalIndexMeta {
-                    row_range_start,
-                    row_range_end,
-                    index_field_id: field_id,
-                    extra_field_ids: None,
-                    source_meta: None,
-                    index_meta: Some(result.index_meta),
-                }),
-            },
-        }
-    }
-
-    #[tokio::test]
-    async fn test_fm_file_set_must_exactly_cover_source_range() {
-        let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
-        let first = make_fm_index_entry("first.fm", 1, 10, 13, 0, 2).await;
-        let error = match GlobalIndexScanner::create(
-            &file_io,
-            "memory:/table",
-            1,
-            i64::MAX,
-            i64::MAX,
-            std::slice::from_ref(&first),
-            &string_schema_fields(),
-        ) {
-            Err(error) => error,
-            Ok(_) => panic!("a partial Java FM file set must fail closed"),
-        };
-        assert!(
-            matches!(error, Error::DataInvalid { message, .. } if message.contains("cover 2 rows, expected 4"))
-        );
-
-        let second = make_fm_index_entry("second.fm", 1, 10, 13, 2, 2).await;
-        assert!(GlobalIndexScanner::create(
-            &file_io,
-            "memory:/table",
-            1,
-            i64::MAX,
-            i64::MAX,
-            &[first, second],
-            &string_schema_fields(),
-        )
-        .unwrap()
-        .is_some());
-    }
-
-    #[test]
-    fn test_mixed_fm_and_btree_select_compatible_index_family() {
-        let btree = GlobalIndexEntry {
-            file_name: "name.btree".to_string(),
-            index_type: GlobalIndexFileKind::BTree,
-            file_size: 1,
-            row_range_start: 0,
-            row_range_end: 9,
-            meta: GlobalIndexEntryMeta::Sorted(BTreeIndexMeta::new(None, None, false)),
-        };
-        let fm = GlobalIndexEntry {
-            file_name: "name.fm".to_string(),
-            index_type: GlobalIndexFileKind::FM,
-            file_size: 1,
-            row_range_start: 0,
-            row_range_end: 9,
-            meta: GlobalIndexEntryMeta::FM {
-                bytes: Vec::new(),
-                first_row_id: 0,
-                row_count: 10,
-            },
-        };
-        let entries = [btree, fm];
-        let data_type = DataType::VarChar(crate::spec::VarCharType::string_type());
-        let literal = [Datum::String("needle".to_string())];
-
-        let contains = [(PredicateOperator::Contains, literal.as_slice(), &data_type)];
-        let selected = select_entries_for_predicates(&entries, &contains);
-        assert_eq!(selected.len(), 1);
-        assert_eq!(selected[0].index_type, GlobalIndexFileKind::FM);
-
-        let equals = [(PredicateOperator::Eq, literal.as_slice(), &data_type)];
-        let selected = select_entries_for_predicates(&entries, &equals);
-        assert_eq!(selected.len(), 1);
-        assert_eq!(selected[0].index_type, GlobalIndexFileKind::BTree);
-    }
-
-    fn int_schema_fields() -> Vec<DataField> {
-        vec![DataField::new(
-            1,
-            "id".to_string(),
-            DataType::Int(crate::spec::IntType::new()),
-        )]
-    }
-
-    fn string_schema_fields() -> Vec<DataField> {
-        vec![DataField::new(
-            1,
-            "name".to_string(),
-            DataType::VarChar(crate::spec::VarCharType::string_type()),
-        )]
-    }
-
-    async fn evaluate_global_index_fast(
-        file_io: &FileIO,
-        table_path: &str,
-        entries: &[IndexManifestEntry],
-        predicates: &[Predicate],
-        fields: &[DataField],
-    ) -> Result<Option<Vec<RowRange>>> {
-        evaluate_global_index_fast_with_fallback_size(
-            file_io,
-            table_path,
-            entries,
-            predicates,
-            fields,
-            i64::MAX,
-            i64::MAX,
-        )
-        .await
-    }
-
-    async fn evaluate_global_index_fast_with_fallback_size(
-        file_io: &FileIO,
-        table_path: &str,
-        entries: &[IndexManifestEntry],
-        predicates: &[Predicate],
-        fields: &[DataField],
-        btree_fallback_scan_max_size: i64,
-        bitmap_fallback_scan_max_size: i64,
-    ) -> Result<Option<Vec<RowRange>>> {
-        super::evaluate_global_index(super::GlobalIndexEvaluation {
-            file_io,
-            table_path,
-            index_entries: entries,
-            predicates,
-            schema_fields: fields,
-            search_mode: GlobalIndexSearchMode::Fast,
-            global_index_thread_num: 32,
-            btree_fallback_scan_max_size,
-            bitmap_fallback_scan_max_size,
-            fm_read_options: FMReadOptions::default(),
-            next_row_id: None,
-            data_ranges: &[],
-        })
-        .await
-    }
-
-    fn two_field_schema_fields() -> Vec<DataField> {
-        vec![
-            DataField::new(
-                1,
-                "id".to_string(),
-                DataType::Int(crate::spec::IntType::new()),
-            ),
-            DataField::new(
-                2,
-                "value".to_string(),
-                DataType::Int(crate::spec::IntType::new()),
-            ),
-        ]
-    }
-
-    fn int_eq(column: &str, index: usize, value: i32) -> Predicate {
-        Predicate::Leaf {
-            column: column.to_string(),
-            index,
-            data_type: DataType::Int(crate::spec::IntType::new()),
-            op: PredicateOperator::Eq,
-            literals: vec![Datum::Int(value)],
-        }
-    }
-
-    #[test]
-    fn test_unindexed_ranges_fast_mode_empty() {
-        let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
-        let meta = BTreeIndexMeta::new(None, None, false);
-        let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
-        let fields = int_schema_fields();
-        let scanner = GlobalIndexScanner::create(
-            &file_io,
-            "memory:/t",
-            32,
-            i64::MAX,
-            i64::MAX,
-            &entries,
-            &fields,
-        )
-        .expect("create scanner")
-        .expect("scanner");
-
-        let ranges = scanner
-            .unindexed_ranges(
-                &int_eq("id", 0, 7),
-                GlobalIndexSearchMode::Fast,
-                Some(100),
-                &[RowRange::new(50, 99)],
-            )
-            .unwrap();
-        assert!(ranges.is_empty());
-    }
-
-    #[test]
-    fn test_unindexed_ranges_full_uses_snapshot_next_row_id() {
-        let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
-        let meta = BTreeIndexMeta::new(None, None, false);
-        let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
-        let fields = int_schema_fields();
-        let scanner = GlobalIndexScanner::create(
-            &file_io,
-            "memory:/t",
-            32,
-            i64::MAX,
-            i64::MAX,
-            &entries,
-            &fields,
-        )
-        .expect("create scanner")
-        .expect("scanner");
-
-        let ranges = scanner
-            .unindexed_ranges(
-                &int_eq("id", 0, 7),
-                GlobalIndexSearchMode::Full,
-                Some(100),
-                &[],
-            )
-            .unwrap();
-        assert_eq!(ranges, vec![RowRange::new(50, 99)]);
-    }
-
-    #[test]
-    fn test_unindexed_ranges_detail_uses_data_file_ranges() {
-        let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
-        let meta = BTreeIndexMeta::new(None, None, false);
-        let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
-        let fields = int_schema_fields();
-        let scanner = GlobalIndexScanner::create(
-            &file_io,
-            "memory:/t",
-            32,
-            i64::MAX,
-            i64::MAX,
-            &entries,
-            &fields,
-        )
-        .expect("create scanner")
-        .expect("scanner");
-
-        let ranges = scanner
-            .unindexed_ranges(
-                &int_eq("id", 0, 7),
-                GlobalIndexSearchMode::Detail,
-                Some(100),
-                &[
-                    RowRange::new(0, 10),
-                    RowRange::new(40, 60),
-                    RowRange::new(80, 90),
-                ],
-            )
-            .unwrap();
-        assert_eq!(ranges, vec![RowRange::new(50, 60), RowRange::new(80, 90)]);
-    }
-
-    #[test]
-    fn test_unindexed_ranges_uses_all_predicate_field_coverage() {
-        let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
-        let meta = BTreeIndexMeta::new(None, None, false);
-        let entries = vec![
-            make_global_index_entry("idx_id", 1, 0, 49, &meta),
-            make_global_index_entry("idx_value", 2, 0, 99, &meta),
-        ];
-        let fields = two_field_schema_fields();
-        let scanner = GlobalIndexScanner::create(
-            &file_io,
-            "memory:/t",
-            32,
-            i64::MAX,
-            i64::MAX,
-            &entries,
-            &fields,
-        )
-        .expect("create scanner")
-        .expect("scanner");
-        let predicate = Predicate::and(vec![int_eq("id", 0, 7), int_eq("value", 1, 8)]);
-
-        let ranges = scanner
-            .unindexed_ranges(&predicate, GlobalIndexSearchMode::Full, Some(100), &[])
-            .unwrap();
-        assert_eq!(ranges, vec![RowRange::new(50, 99)]);
-    }
-
-    #[test]
-    fn test_unindexed_ranges_missing_field_coverage_reads_all_data_ranges() {
-        let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
-        let meta = BTreeIndexMeta::new(None, None, false);
-        let entries = vec![make_global_index_entry("idx_id", 1, 0, 49, &meta)];
-        let fields = two_field_schema_fields();
-        let scanner = GlobalIndexScanner::create(
-            &file_io,
-            "memory:/t",
-            32,
-            i64::MAX,
-            i64::MAX,
-            &entries,
-            &fields,
-        )
-        .expect("create scanner")
-        .expect("scanner");
-        let predicate = Predicate::and(vec![int_eq("id", 0, 7), int_eq("value", 1, 8)]);
-
-        let ranges = scanner
-            .unindexed_ranges(&predicate, GlobalIndexSearchMode::Full, Some(100), &[])
-            .unwrap();
-        assert_eq!(ranges, vec![RowRange::new(0, 99)]);
-    }
-
-    #[test]
-    fn test_unindexed_ranges_counts_extra_field_coverage() {
-        let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
-        let meta = BTreeIndexMeta::new(None, None, false);
-        let mut entry = make_global_index_entry("idx_id_value", 1, 0, 99, &meta);
-        entry
-            .index_file
-            .global_index_meta
-            .as_mut()
-            .unwrap()
-            .extra_field_ids = Some(vec![2]);
-        let fields = two_field_schema_fields();
-        let scanner = GlobalIndexScanner::create(
-            &file_io,
-            "memory:/t",
-            32,
-            i64::MAX,
-            i64::MAX,
-            &[entry],
-            &fields,
-        )
-        .expect("create scanner")
-        .expect("scanner");
-
-        let ranges = scanner
-            .unindexed_ranges(
-                &int_eq("value", 1, 8),
-                GlobalIndexSearchMode::Full,
-                Some(100),
-                &[],
-            )
-            .unwrap();
-        assert!(ranges.is_empty());
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_extra_field_only_without_composite_reader_falls_back() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let mut entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
-        entry
-            .index_file
-            .global_index_meta
-            .as_mut()
-            .unwrap()
-            .extra_field_ids = Some(vec![2]);
-        let fields = two_field_schema_fields();
-        let predicates = vec![int_eq("value", 1, 50)];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &[entry], &predicates, &fields)
-                .await
-                .unwrap();
-        assert!(
-            result.is_none(),
-            "extra-field-only predicates must fall back until composite-key btree reads are supported"
-        );
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_eq() {
-        let (file_io, table_path, file_name, tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let mut entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
-        entry.index_file.file_size = std::fs::metadata(tmp.path().join("index").join(&file_name))
-            .unwrap()
-            .len() as i64;
-        let entries = vec![entry];
-        let fields = int_schema_fields();
-
-        // key=50 -> row_id=25, offset by row_range_start=0 -> global row_id=25
-        let predicates = vec![Predicate::Leaf {
-            column: "id".to_string(),
-            index: 0,
-            data_type: DataType::Int(crate::spec::IntType::new()),
-            op: PredicateOperator::Eq,
-            literals: vec![Datum::Int(50)],
-        }];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                .await
-                .unwrap();
-        let ranges = result.unwrap();
-        assert_eq!(ranges, vec![RowRange::new(25, 25)]);
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_uses_known_file_size() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let mut entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
-        entry.index_file.file_size = 1;
-
-        let error = evaluate_global_index_fast(
-            &file_io,
-            &table_path,
-            &[entry],
-            &[int_eq("id", 0, 50)],
-            &int_schema_fields(),
-        )
-        .await
-        .expect_err("the known file size should be used without a metadata lookup");
-
-        assert!(matches!(
-            error,
-            crate::Error::DataInvalid { message, .. }
-                if message.contains("Failed to open BTree index file")
-        ));
-    }
-
-    #[tokio::test]
-    async fn test_missing_index_meta_returns_error() {
-        let (file_io, table_path, file_name, tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let second_file_name = "btree_int_100_no_compress_2.bin";
-        std::fs::copy(
-            tmp.path().join("index").join(&file_name),
-            tmp.path().join("index").join(second_file_name),
-        )
-        .unwrap();
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let valid_entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
-        let mut invalid_entry = make_global_index_entry(second_file_name, 1, 100, 199, &meta);
-        invalid_entry
-            .index_file
-            .global_index_meta
-            .as_mut()
-            .unwrap()
-            .index_meta = None;
-
-        let error = evaluate_global_index_fast(
-            &file_io,
-            &table_path,
-            &[valid_entry, invalid_entry],
-            &[int_eq("id", 0, 50)],
-            &int_schema_fields(),
-        )
-        .await
-        .expect_err("missing sorted index metadata must fail the scan");
-
-        assert!(matches!(
-            error,
-            crate::Error::DataInvalid { message, .. }
-                if message.contains(second_file_name)
-        ));
-    }
-
-    #[tokio::test]
-    async fn test_invalid_index_meta_returns_error() {
-        let (file_io, table_path, file_name, tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let second_file_name = "btree_int_100_no_compress_2.bin";
-        std::fs::copy(
-            tmp.path().join("index").join(&file_name),
-            tmp.path().join("index").join(second_file_name),
-        )
-        .unwrap();
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let valid_entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
-        let mut invalid_entry = make_global_index_entry(second_file_name, 1, 100, 199, &meta);
-        let mut invalid_meta = vec![0; 9];
-        invalid_meta[..4].copy_from_slice(&10i32.to_le_bytes());
-        invalid_entry
-            .index_file
-            .global_index_meta
-            .as_mut()
-            .unwrap()
-            .index_meta = Some(invalid_meta);
-
-        let error = evaluate_global_index_fast(
-            &file_io,
-            &table_path,
-            &[valid_entry, invalid_entry],
-            &[int_eq("id", 0, 50)],
-            &int_schema_fields(),
-        )
-        .await
-        .expect_err("invalid sorted index metadata must fail the scan");
-
-        assert!(matches!(
-            error,
-            crate::Error::DataInvalid {
-                message,
-                source: Some(_),
-            } if message.contains(second_file_name)
-        ));
-    }
-
-    #[tokio::test]
-    async fn test_missing_global_index_meta_returns_error() {
-        let (file_io, table_path, file_name, tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let second_file_name = "btree_int_100_no_compress_2.bin";
-        std::fs::copy(
-            tmp.path().join("index").join(&file_name),
-            tmp.path().join("index").join(second_file_name),
-        )
-        .unwrap();
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let valid_entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
-        let mut invalid_entry = make_global_index_entry(second_file_name, 1, 100, 199, &meta);
-        invalid_entry.index_file.global_index_meta = None;
-
-        let error = evaluate_global_index_fast(
-            &file_io,
-            &table_path,
-            &[valid_entry, invalid_entry],
-            &[int_eq("id", 0, 50)],
-            &int_schema_fields(),
-        )
-        .await
-        .expect_err("missing global index metadata must fail the scan");
-
-        assert!(matches!(
-            error,
-            crate::Error::DataInvalid { message, .. }
-                if message.contains(second_file_name)
-        ));
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_java_bitmap_golden_index_eq_and_null() {
-        let data_type = DataType::VarChar(crate::spec::VarCharType::string_type());
-        let (file_io, table_path, file_name, meta, tmp) = setup_java_bitmap_testdata_table();
-        let mut entry = make_global_index_entry_with_type(
-            BITMAP_GLOBAL_INDEX_TYPE,
-            &file_name,
-            1,
-            100,
-            109,
-            &meta,
-        );
-        entry.index_file.file_size = std::fs::metadata(tmp.path().join("index").join(&file_name))
-            .unwrap()
-            .len() as i64;
-        let entries = vec![entry];
-        let fields = string_schema_fields();
-        assert_eq!(meta.first_key, Some(b"alpha".to_vec()));
-        assert_eq!(meta.last_key, Some(b"office".to_vec()));
-        assert!(meta.has_nulls);
-
-        let eq_predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type: data_type.clone(),
-            op: PredicateOperator::Eq,
-            literals: vec![Datum::String("k2".to_string())],
-        }];
-        let eq_result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &eq_predicates, &fields)
-                .await
-                .unwrap();
-        assert_eq!(eq_result.unwrap(), vec![RowRange::new(105, 106)]);
-
-        let null_predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type,
-            op: PredicateOperator::IsNull,
-            literals: vec![],
-        }];
-        let null_result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &null_predicates, &fields)
-                .await
-                .unwrap();
-        assert_eq!(null_result.unwrap(), vec![RowRange::new(104, 104)]);
-    }
-
-    async fn assert_bitmap_int_fixture(file_name: &str) {
-        let data_type = DataType::Int(crate::spec::IntType::new());
-        let (file_io, table_path, file_name, meta, _tmp) = setup_bitmap_testdata_table(file_name);
-        let entries = vec![make_global_index_entry_with_type(
-            BITMAP_GLOBAL_INDEX_TYPE,
-            &file_name,
-            1,
-            100,
-            105,
-            &meta,
-        )];
-        let fields = int_schema_fields();
-        assert_eq!(meta.first_key, Some(le_int_key(-1)));
-        assert_eq!(meta.last_key, Some(le_int_key(256)));
-        assert!(meta.has_nulls);
-
-        let cases = [
-            (
-                PredicateOperator::Eq,
-                vec![Datum::Int(0)],
-                vec![RowRange::new(101, 102)],
-            ),
-            (
-                PredicateOperator::Eq,
-                vec![Datum::Int(256)],
-                vec![RowRange::new(104, 104)],
-            ),
-            (
-                PredicateOperator::In,
-                vec![Datum::Int(-1), Datum::Int(1), Datum::Int(256)],
-                vec![RowRange::new(100, 100), RowRange::new(103, 104)],
-            ),
-            (
-                PredicateOperator::NotEq,
-                vec![Datum::Int(0)],
-                vec![RowRange::new(100, 100), RowRange::new(103, 104)],
-            ),
-            (
-                PredicateOperator::NotIn,
-                vec![Datum::Int(-1), Datum::Int(1), Datum::Int(256)],
-                vec![RowRange::new(101, 102)],
-            ),
-            (
-                PredicateOperator::IsNull,
-                vec![],
-                vec![RowRange::new(105, 105)],
-            ),
-        ];
-
-        for (op, literals, expected) in cases {
-            let predicates = vec![Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: data_type.clone(),
-                op,
-                literals,
-            }];
-            let result =
-                evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                    .await
-                    .unwrap()
-                    .unwrap();
-            assert_eq!(result, expected, "{file_name}: {op}");
-        }
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_java_logical_order_bitmap_int_fixture() {
-        assert_bitmap_int_fixture("bitmap_int_logical_java.index").await;
-    }
-
-    async fn assert_bitmap_nan_equality_uses_direct_lookup(
-        data_type: DataType,
-        nan_literals: [Datum; 3],
-        zero: Datum,
-    ) {
-        let output = VecFileWrite::new();
-        let captured = output.clone();
-        let mut writer = BitmapGlobalIndexWriter::new(
-            Box::new(output),
-            1,
-            BlockCompressionType::None,
-            make_bitmap_key_comparator(&data_type),
-        );
-        for (row_id, literal) in nan_literals.iter().enumerate() {
-            let key = serialize_bitmap_datum(literal, &data_type);
-            writer.write(Some(&key), row_id as i64).unwrap();
-        }
-        let zero_key = serialize_bitmap_datum(&zero, &data_type);
-        writer.write(Some(&zero_key), 3).unwrap();
-        let write_result = writer.finish().await.unwrap();
-        let bytes = captured.to_vec();
-
-        let tmp = tempfile::tempdir().unwrap();
-        let index_dir = tmp.path().join("index");
-        std::fs::create_dir_all(&index_dir).unwrap();
-        let file_name = "bitmap-current.index";
-        std::fs::write(index_dir.join(file_name), &bytes).unwrap();
-        let table_path = format!("file://{}", tmp.path().display());
-        let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
-
-        let mut entry = make_global_index_entry_with_type(
-            BITMAP_GLOBAL_INDEX_TYPE,
-            file_name,
-            1,
-            100,
-            103,
-            &write_result.meta,
-        );
-        entry.index_file.file_size = bytes.len() as i64;
-        let entries = vec![entry];
-        let fields = vec![DataField::new(1, "id".to_string(), data_type.clone())];
-        let cases = [
-            (PredicateOperator::Eq, vec![nan_literals[0].clone()]),
-            (
-                PredicateOperator::In,
-                vec![nan_literals[1].clone(), nan_literals[2].clone()],
-            ),
-        ];
-
-        for (op, literals) in cases {
-            let predicates = vec![Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: data_type.clone(),
-                op,
-                literals,
-            }];
-            let result = evaluate_global_index_fast_with_fallback_size(
-                &file_io,
-                &table_path,
-                &entries,
-                &predicates,
-                &fields,
-                i64::MAX,
-                0,
-            )
-            .await
-            .unwrap()
-            .unwrap();
-            assert_eq!(result, vec![RowRange::new(100, 102)], "{data_type:?}: {op}");
-        }
-    }
-
-    #[tokio::test]
-    async fn test_bitmap_nan_equality_uses_direct_lookup_with_fallback_scan_disabled() {
-        assert_bitmap_nan_equality_uses_direct_lookup(
-            DataType::Float(crate::spec::FloatType::new()),
-            [
-                Datum::Float(f32::from_bits(0xffc0_0001)),
-                Datum::Float(f32::from_bits(0x7fc0_0010)),
-                Datum::Float(f32::NAN),
-            ],
-            Datum::Float(0.0),
-        )
-        .await;
-        assert_bitmap_nan_equality_uses_direct_lookup(
-            DataType::Double(crate::spec::DoubleType::new()),
-            [
-                Datum::Double(f64::from_bits(0xfff8_0000_0000_0001)),
-                Datum::Double(f64::from_bits(0x7ff8_0000_0000_0010)),
-                Datum::Double(f64::NAN),
-            ],
-            Datum::Double(0.0),
-        )
-        .await;
-    }
-
-    fn legacy_floating_comparator(data_type: &DataType) -> BoxedCmp {
-        match data_type {
-            DataType::Float(_) => Box::new(|left, right| {
-                let left = f32::from_le_bytes(left.try_into().unwrap());
-                let right = f32::from_le_bytes(right.try_into().unwrap());
-                left.total_cmp(&right)
-            }),
-            DataType::Double(_) => Box::new(|left, right| {
-                let left = f64::from_le_bytes(left.try_into().unwrap());
-                let right = f64::from_le_bytes(right.try_into().unwrap());
-                left.total_cmp(&right)
-            }),
-            _ => unreachable!("legacy floating comparator requires Float or Double"),
-        }
-    }
-
-    async fn assert_legacy_floating_btree(
-        file_name: &str,
-        data_type: DataType,
-        nan_keys: Vec<Vec<u8>>,
-        nan_literals: Vec<Datum>,
-        zero_key: Vec<u8>,
-        zero_literal: Datum,
-    ) {
-        let mut rows = nan_keys
-            .into_iter()
-            .enumerate()
-            .map(|(row_id, key)| (key, row_id as i64))
-            .collect::<Vec<_>>();
-        rows.push((zero_key, 3));
-        let cmp = legacy_floating_comparator(&data_type);
-        rows.sort_by(|left, right| cmp(&left.0, &right.0));
-        let expected_first_key = rows.first().unwrap().0.clone();
-        let expected_last_key = rows.last().unwrap().0.clone();
-
-        let output = VecFileWrite::new();
-        let captured = output.clone();
-        let mut writer =
-            BTreeIndexWriter::with_comparator(Box::new(output), 1, BlockCompressionType::None, cmp);
-        for (key, row_id) in rows {
-            writer.write(Some(&key), row_id).await.unwrap();
-        }
-        let write_result = writer.finish().await.unwrap();
-        assert_eq!(write_result.meta.first_key, Some(expected_first_key));
-        assert_eq!(write_result.meta.last_key, Some(expected_last_key));
-
-        let tmp = tempfile::tempdir().unwrap();
-        let index_dir = tmp.path().join("index");
-        std::fs::create_dir_all(&index_dir).unwrap();
-        std::fs::write(index_dir.join(file_name), captured.to_vec()).unwrap();
-        let table_path = format!("file://{}", tmp.path().display());
-        let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
-        let entries = vec![make_global_index_entry(
-            file_name,
-            1,
-            100,
-            103,
-            &write_result.meta,
-        )];
-        let fields = vec![DataField::new(1, "id".to_string(), data_type.clone())];
-        let cases = [
-            (
-                PredicateOperator::Eq,
-                vec![zero_literal.clone()],
-                vec![RowRange::new(103, 103)],
-            ),
-            (
-                PredicateOperator::Eq,
-                vec![nan_literals[0].clone()],
-                vec![RowRange::new(100, 100)],
-            ),
-            (
-                PredicateOperator::In,
-                vec![
-                    nan_literals[0].clone(),
-                    nan_literals[1].clone(),
-                    zero_literal,
-                ],
-                vec![RowRange::new(100, 101), RowRange::new(103, 103)],
-            ),
-        ];
-
-        for (op, literals, expected) in cases {
-            let predicates = vec![Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: data_type.clone(),
-                op,
-                literals,
-            }];
-            let result =
-                evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                    .await
-                    .unwrap()
-                    .unwrap();
-            assert_eq!(result, expected, "{file_name}: {op}");
-        }
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_legacy_float_btree() {
-        let nan_bits = [0xffc0_0001u32, 0xffc0_0010, 0xffff_1234];
-        assert_legacy_floating_btree(
-            "btree_float_legacy_rust.index",
-            DataType::Float(crate::spec::FloatType::new()),
-            nan_bits
-                .iter()
-                .map(|bits| bits.to_le_bytes().to_vec())
-                .collect(),
-            nan_bits
-                .iter()
-                .map(|bits| Datum::Float(f32::from_bits(*bits)))
-                .collect(),
-            0.0f32.to_le_bytes().to_vec(),
-            Datum::Float(0.0),
-        )
-        .await;
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_legacy_double_btree() {
-        let nan_bits = [
-            0xfff8_0000_0000_0001u64,
-            0xfff8_0000_0000_0010,
-            0xffff_1234_5678_9abc,
-        ];
-        assert_legacy_floating_btree(
-            "btree_double_legacy_rust.index",
-            DataType::Double(crate::spec::DoubleType::new()),
-            nan_bits
-                .iter()
-                .map(|bits| bits.to_le_bytes().to_vec())
-                .collect(),
-            nan_bits
-                .iter()
-                .map(|bits| Datum::Double(f64::from_bits(*bits)))
-                .collect(),
-            0.0f64.to_le_bytes().to_vec(),
-            Datum::Double(0.0),
-        )
-        .await;
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_java_bitmap_golden_index_string_fallback_scan() {
-        let data_type = DataType::VarChar(crate::spec::VarCharType::string_type());
-        let (file_io, table_path, file_name, meta, tmp) = setup_java_bitmap_testdata_table();
-        let file_size = std::fs::metadata(tmp.path().join("index").join(&file_name))
-            .unwrap()
-            .len() as i64;
-        let mut entry = make_global_index_entry_with_type(
-            BITMAP_GLOBAL_INDEX_TYPE,
-            &file_name,
-            1,
-            100,
-            109,
-            &meta,
-        );
-        entry.index_file.file_size = file_size;
-        let entries = vec![entry];
-        let fields = string_schema_fields();
-
-        let ends_with_predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type: data_type.clone(),
-            op: PredicateOperator::EndsWith,
-            literals: vec![Datum::String("ta".to_string())],
-        }];
-        let ends_with_result = evaluate_global_index_fast(
-            &file_io,
-            &table_path,
-            &entries,
-            &ends_with_predicates,
-            &fields,
-        )
-        .await
-        .unwrap();
-        assert_eq!(
-            ends_with_result.unwrap(),
-            vec![RowRange::new(101, 101), RowRange::new(103, 103)]
-        );
-
-        let contains_predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type: data_type.clone(),
-            op: PredicateOperator::Contains,
-            literals: vec![Datum::String("ph".to_string())],
-        }];
-        let contains_result = evaluate_global_index_fast(
-            &file_io,
-            &table_path,
-            &entries,
-            &contains_predicates,
-            &fields,
-        )
-        .await
-        .unwrap();
-        assert_eq!(
-            contains_result.unwrap(),
-            vec![RowRange::new(100, 100), RowRange::new(102, 102)]
-        );
-
-        let like_predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type: data_type.clone(),
-            op: PredicateOperator::Like,
-            literals: vec![Datum::String("%ha%".to_string())],
-        }];
-        let like_result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &like_predicates, &fields)
-                .await
-                .unwrap();
-        assert_eq!(
-            like_result.unwrap(),
-            vec![RowRange::new(100, 100), RowRange::new(102, 102)]
-        );
-
-        let less_than_predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type: data_type.clone(),
-            op: PredicateOperator::Lt,
-            literals: vec![Datum::String("delta".to_string())],
-        }];
-        let less_than_result = evaluate_global_index_fast(
-            &file_io,
-            &table_path,
-            &entries,
-            &less_than_predicates,
-            &fields,
-        )
-        .await
-        .unwrap();
-        assert_eq!(less_than_result.unwrap(), vec![RowRange::new(100, 102)]);
-
-        let mut over_limit_entry = make_global_index_entry_with_type(
-            BITMAP_GLOBAL_INDEX_TYPE,
-            &file_name,
-            1,
-            100,
-            109,
-            &meta,
-        );
-        over_limit_entry.index_file.file_size = file_size;
-        let over_limit_entries = vec![over_limit_entry];
-        let over_limit_less_than = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            &table_path,
-            &over_limit_entries,
-            &less_than_predicates,
-            &fields,
-            i64::MAX,
-            file_size - 1,
-        )
-        .await
-        .unwrap();
-        assert!(
-            over_limit_less_than.is_none(),
-            "range predicates require fallback dictionary scans and should be unsupported over budget"
-        );
-
-        let no_match_contains = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type: data_type.clone(),
-            op: PredicateOperator::Contains,
-            literals: vec![Datum::String("zz".to_string())],
-        }];
-        let over_limit_result = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            &table_path,
-            &over_limit_entries,
-            &no_match_contains,
-            &fields,
-            i64::MAX,
-            file_size - 1,
-        )
-        .await
-        .unwrap();
-        assert!(
-            over_limit_result.is_none(),
-            "fallback scans over budget should be unsupported instead of returning full coverage"
-        );
-
-        let direct_with_over_limit_fallback = vec![Predicate::and(vec![
-            Predicate::Leaf {
-                column: "name".to_string(),
-                index: 0,
-                data_type: data_type.clone(),
-                op: PredicateOperator::Eq,
-                literals: vec![Datum::String("k2".to_string())],
-            },
-            Predicate::Leaf {
-                column: "name".to_string(),
-                index: 0,
-                data_type,
-                op: PredicateOperator::Contains,
-                literals: vec![Datum::String("zz".to_string())],
-            },
-        ])];
-        let direct_result = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            &table_path,
-            &over_limit_entries,
-            &direct_with_over_limit_fallback,
-            &fields,
-            i64::MAX,
-            file_size - 1,
-        )
-        .await
-        .unwrap();
-        assert_eq!(direct_result.unwrap(), vec![RowRange::new(105, 106)]);
-    }
-
-    #[tokio::test]
-    async fn test_btree_fallback_scan_over_limit_is_unsupported() {
-        let (file_io, table_path, file_name, tmp) =
-            setup_testdata_table("btree_varchar_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(b"a".to_vec()), Some(b"yyyy".to_vec()), false);
-        let fields = string_schema_fields();
-        let data_type = DataType::VarChar(crate::spec::VarCharType::string_type());
-        let predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type,
-            op: PredicateOperator::Contains,
-            literals: vec![Datum::String("not-present".to_string())],
-        }];
-
-        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        let exact_result = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            &table_path,
-            &entries,
-            &predicates,
-            &fields,
-            i64::MAX,
-            i64::MAX,
-        )
-        .await
-        .unwrap();
-        assert_eq!(exact_result.unwrap(), Vec::<RowRange>::new());
-
-        let mut over_limit_entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        over_limit_entries[0].index_file.file_size = 2;
-        let over_limit_result = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            &table_path,
-            &over_limit_entries,
-            &predicates,
-            &fields,
-            1,
-            i64::MAX,
-        )
-        .await
-        .unwrap();
-        assert!(
-            over_limit_result.is_none(),
-            "fallback scans over budget should be unsupported instead of returning full coverage"
-        );
-
-        let second_file_name = "btree_varchar_100_no_compress_2.bin";
-        std::fs::copy(
-            tmp.path().join("index").join(&file_name),
-            tmp.path().join("index").join(second_file_name),
-        )
-        .unwrap();
-        let mut first = make_global_index_entry(&file_name, 1, 0, 99, &meta);
-        first.index_file.file_size = 1;
-        let mut second = make_global_index_entry(second_file_name, 1, 100, 199, &meta);
-        second.index_file.file_size = 1;
-        let total_over_limit_result = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            &table_path,
-            &[first, second],
-            &predicates,
-            &fields,
-            1,
-            i64::MAX,
-        )
-        .await
-        .unwrap();
-        assert!(
-            total_over_limit_result.is_none(),
-            "fallback budget should use selected files' total size, not per-file size"
-        );
-    }
-
-    #[tokio::test]
-    async fn test_fallback_scan_over_limit_with_mixed_index_kinds_is_unsupported() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_varchar_100_no_compress.bin");
-        let btree_meta = BTreeIndexMeta::new(Some(b"a".to_vec()), Some(b"yyyy".to_vec()), false);
-        let bitmap_meta = BTreeIndexMeta::new(Some(b"m".to_vec()), Some(b"z".to_vec()), false);
-        let fields = string_schema_fields();
-        let predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type: DataType::VarChar(crate::spec::VarCharType::string_type()),
-            op: PredicateOperator::Lt,
-            literals: vec![Datum::String("delta".to_string())],
-        }];
-
-        let mut btree = make_global_index_entry_with_type(
-            BTREE_GLOBAL_INDEX_TYPE,
-            &file_name,
-            1,
-            0,
-            99,
-            &btree_meta,
-        );
-        btree.index_file.file_size = 2;
-        let mut bitmap = make_global_index_entry_with_type(
-            BITMAP_GLOBAL_INDEX_TYPE,
-            "bitmap-no-match.index",
-            1,
-            100,
-            199,
-            &bitmap_meta,
-        );
-        bitmap.index_file.file_size = 1;
-
-        let result = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            &table_path,
-            &[btree, bitmap],
-            &predicates,
-            &fields,
-            1,
-            i64::MAX,
-        )
-        .await
-        .unwrap();
-        assert!(
-            result.is_none(),
-            "an over-budget selected BTree file must stay unsupported even if bitmap files are pruned by metadata"
-        );
-    }
-
-    #[tokio::test]
-    async fn test_fallback_preflight_happens_before_shard_io() {
-        let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
-        let table_path = "memory:/missing-index-files";
-        let meta = BTreeIndexMeta::new(Some(b"a".to_vec()), Some(b"z".to_vec()), false);
-        let fields = string_schema_fields();
-        let predicates = vec![Predicate::Leaf {
-            column: "name".to_string(),
-            index: 0,
-            data_type: DataType::VarChar(crate::spec::VarCharType::string_type()),
-            op: PredicateOperator::Contains,
-            literals: vec![Datum::String("middle".to_string())],
-        }];
-
-        let mut btree = make_global_index_entry_with_type(
-            BTREE_GLOBAL_INDEX_TYPE,
-            "missing-btree.index",
-            1,
-            0,
-            99,
-            &meta,
-        );
-        btree.index_file.file_size = 1;
-        let mut bitmap = make_global_index_entry_with_type(
-            BITMAP_GLOBAL_INDEX_TYPE,
-            "missing-bitmap.index",
-            1,
-            100,
-            199,
-            &meta,
-        );
-        bitmap.index_file.file_size = 1;
-
-        let result = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            table_path,
-            &[btree, bitmap],
-            &predicates,
-            &fields,
-            1,
-            0,
-        )
-        .await
-        .expect("fallback must be decided before opening an earlier shard");
-
-        assert!(result.is_none());
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_full_mode_includes_unindexed_tail() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        let fields = int_schema_fields();
-        let predicates = vec![int_eq("id", 0, 50)];
-
-        let result = super::evaluate_global_index(super::GlobalIndexEvaluation {
-            file_io: &file_io,
-            table_path: &table_path,
-            index_entries: &entries,
-            predicates: &predicates,
-            schema_fields: &fields,
-            search_mode: GlobalIndexSearchMode::Full,
-            global_index_thread_num: 32,
-            btree_fallback_scan_max_size: i64::MAX,
-            bitmap_fallback_scan_max_size: i64::MAX,
-            fm_read_options: FMReadOptions::default(),
-            next_row_id: Some(150),
-            data_ranges: &[],
-        })
-        .await
-        .unwrap();
-
-        assert_eq!(
-            result.unwrap(),
-            vec![RowRange::new(25, 25), RowRange::new(100, 149)]
-        );
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_and_uses_evaluated_field_coverage_for_raw_fallback() {
-        let src = format!(
-            "{}/testdata/btree/btree_int_100_no_compress.bin",
-            env!("CARGO_MANIFEST_DIR")
-        );
-        let tmp = tempfile::tempdir().unwrap();
-        let index_dir = tmp.path().join("index");
-        std::fs::create_dir_all(&index_dir).unwrap();
-        std::fs::copy(&src, index_dir.join("index_part1.bin")).unwrap();
-        std::fs::copy(&src, index_dir.join("index_part2.bin")).unwrap();
-
-        let table_path = format!("file://{}", tmp.path().display());
-        let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-
-        let mut first = make_global_index_entry("index_part1.bin", 1, 0, 49, &meta);
-        first
-            .index_file
-            .global_index_meta
-            .as_mut()
-            .unwrap()
-            .extra_field_ids = Some(vec![2]);
-        let second = make_global_index_entry("index_part2.bin", 1, 50, 99, &meta);
-        let entries = vec![first, second];
-        let fields = two_field_schema_fields();
-
-        let predicates = vec![Predicate::and(vec![
-            int_eq("id", 0, 50),
-            int_eq("value", 1, 8),
-        ])];
-        let result = super::evaluate_global_index(super::GlobalIndexEvaluation {
-            file_io: &file_io,
-            table_path: &table_path,
-            index_entries: &entries,
-            predicates: &predicates,
-            schema_fields: &fields,
-            search_mode: GlobalIndexSearchMode::Full,
-            global_index_thread_num: 32,
-            btree_fallback_scan_max_size: i64::MAX,
-            bitmap_fallback_scan_max_size: i64::MAX,
-            fm_read_options: FMReadOptions::default(),
-            next_row_id: Some(100),
-            data_ranges: &[],
-        })
-        .await
-        .unwrap();
-
-        assert_eq!(
-            result.unwrap(),
-            vec![RowRange::new(25, 25), RowRange::new(75, 75)],
-            "raw fallback should use only the id field that was actually evaluated; \
-             the unevaluated extra field must not widen or narrow fallback coverage"
-        );
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_detail_mode_uses_data_ranges() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        let fields = int_schema_fields();
-        let predicates = vec![int_eq("id", 0, 50)];
-
-        let data_ranges = [RowRange::new(90, 120), RowRange::new(140, 145)];
-        let result = super::evaluate_global_index(super::GlobalIndexEvaluation {
-            file_io: &file_io,
-            table_path: &table_path,
-            index_entries: &entries,
-            predicates: &predicates,
-            schema_fields: &fields,
-            search_mode: GlobalIndexSearchMode::Detail,
-            global_index_thread_num: 32,
-            btree_fallback_scan_max_size: i64::MAX,
-            bitmap_fallback_scan_max_size: i64::MAX,
-            fm_read_options: FMReadOptions::default(),
-            next_row_id: Some(150),
-            data_ranges: &data_ranges,
-        })
-        .await
-        .unwrap();
-
-        assert_eq!(
-            result.unwrap(),
-            vec![
-                RowRange::new(25, 25),
-                RowRange::new(100, 120),
-                RowRange::new(140, 145),
-            ]
-        );
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_range() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        let fields = int_schema_fields();
-
-        // keys 10..=20 -> keys 10,12,14,16,18,20 -> row_ids 5,6,7,8,9,10
-        let predicates = vec![
-            Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: DataType::Int(crate::spec::IntType::new()),
-                op: PredicateOperator::GtEq,
-                literals: vec![Datum::Int(10)],
-            },
-            Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: DataType::Int(crate::spec::IntType::new()),
-                op: PredicateOperator::LtEq,
-                literals: vec![Datum::Int(20)],
-            },
-        ];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                .await
-                .unwrap();
-        let ranges = result.unwrap();
-        assert_eq!(ranges, vec![RowRange::new(5, 10)]);
-
-        let mut over_limit_entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        over_limit_entries[0].index_file.file_size = 2;
-        let over_limit_result = evaluate_global_index_fast_with_fallback_size(
-            &file_io,
-            &table_path,
-            &over_limit_entries,
-            &predicates,
-            &fields,
-            1,
-            i64::MAX,
-        )
-        .await
-        .unwrap();
-        assert!(
-            over_limit_result.is_none(),
-            "between/range predicates require fallback scans and should be unsupported over budget"
-        );
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_in() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        let fields = int_schema_fields();
-
-        // IN(0, 50, 198) -> row_ids 0, 25, 99
-        let predicates = vec![Predicate::Leaf {
-            column: "id".to_string(),
-            index: 0,
-            data_type: DataType::Int(crate::spec::IntType::new()),
-            op: PredicateOperator::In,
-            literals: vec![Datum::Int(0), Datum::Int(50), Datum::Int(198)],
-        }];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                .await
-                .unwrap();
-        let ranges = result.unwrap();
-        assert_eq!(
-            ranges,
-            vec![
-                RowRange::new(0, 0),
-                RowRange::new(25, 25),
-                RowRange::new(99, 99)
-            ]
-        );
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_no_match() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        let fields = int_schema_fields();
-
-        // key=999 doesn't exist
-        let predicates = vec![Predicate::Leaf {
-            column: "id".to_string(),
-            index: 0,
-            data_type: DataType::Int(crate::spec::IntType::new()),
-            op: PredicateOperator::Eq,
-            literals: vec![Datum::Int(999)],
-        }];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                .await
-                .unwrap();
-        let ranges = result.unwrap();
-        assert!(ranges.is_empty());
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_with_row_offset() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        // row_range_start=1000 simulates an offset
-        let entries = vec![make_global_index_entry(&file_name, 1, 1000, 1099, &meta)];
-        let fields = int_schema_fields();
-
-        // key=50 -> local row_id=25, offset -> global row_id=1025
-        let predicates = vec![Predicate::Leaf {
-            column: "id".to_string(),
-            index: 0,
-            data_type: DataType::Int(crate::spec::IntType::new()),
-            op: PredicateOperator::Eq,
-            literals: vec![Datum::Int(50)],
-        }];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                .await
-                .unwrap();
-        let ranges = result.unwrap();
-        assert_eq!(ranges, vec![RowRange::new(1025, 1025)]);
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_unknown_column() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        let fields = int_schema_fields();
-
-        // Column "unknown" not in schema -> None (can't evaluate)
-        let predicates = vec![Predicate::Leaf {
-            column: "unknown".to_string(),
-            index: 0,
-            data_type: DataType::Int(crate::spec::IntType::new()),
-            op: PredicateOperator::Eq,
-            literals: vec![Datum::Int(50)],
-        }];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                .await
-                .unwrap();
-        assert!(result.is_none());
-    }
-
-    #[tokio::test]
-    async fn test_evaluate_global_index_multi_field_and() {
-        // Two fields, each with its own btree index file (same data, different field_id).
-        // btree_int_100_no_compress.bin: keys 0,2,4,...,198 -> row_ids 0,1,...,99
-        let src = format!(
-            "{}/testdata/btree/btree_int_100_no_compress.bin",
-            env!("CARGO_MANIFEST_DIR")
-        );
-        let tmp = tempfile::tempdir().unwrap();
-        let index_dir = tmp.path().join("index");
-        std::fs::create_dir_all(&index_dir).unwrap();
-        std::fs::copy(&src, index_dir.join("index_field1.bin")).unwrap();
-        std::fs::copy(&src, index_dir.join("index_field2.bin")).unwrap();
-
-        let table_path = format!("file://{}", tmp.path().display());
-        let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
-
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-
-        let fields = vec![
-            DataField::new(
-                1,
-                "id".to_string(),
-                DataType::Int(crate::spec::IntType::new()),
-            ),
-            DataField::new(
-                2,
-                "value".to_string(),
-                DataType::Int(crate::spec::IntType::new()),
-            ),
-        ];
-
-        let entries = vec![
-            make_global_index_entry("index_field1.bin", 1, 0, 99, &meta),
-            make_global_index_entry("index_field2.bin", 2, 0, 99, &meta),
-        ];
-
-        // id >= 40 AND id <= 60 → keys 40,42,...,60 → row_ids 20..30
-        // value >= 44 AND value <= 52 → keys 44,46,48,50,52 → row_ids 22..26
-        // AND → intersect [20..30] and [22..26] = [22..26]
-        let predicates = vec![
-            Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: DataType::Int(crate::spec::IntType::new()),
-                op: PredicateOperator::GtEq,
-                literals: vec![Datum::Int(40)],
-            },
-            Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: DataType::Int(crate::spec::IntType::new()),
-                op: PredicateOperator::LtEq,
-                literals: vec![Datum::Int(60)],
-            },
-            Predicate::Leaf {
-                column: "value".to_string(),
-                index: 1,
-                data_type: DataType::Int(crate::spec::IntType::new()),
-                op: PredicateOperator::GtEq,
-                literals: vec![Datum::Int(44)],
-            },
-            Predicate::Leaf {
-                column: "value".to_string(),
-                index: 1,
-                data_type: DataType::Int(crate::spec::IntType::new()),
-                op: PredicateOperator::LtEq,
-                literals: vec![Datum::Int(52)],
-            },
-        ];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                .await
-                .unwrap();
-        let ranges = result.unwrap();
-        assert_eq!(ranges, vec![RowRange::new(22, 26)]);
-    }
-
-    #[tokio::test]
-    async fn test_multi_field_and_shares_query_concurrency_budget() {
-        let src = format!(
-            "{}/testdata/btree/btree_int_100_no_compress.bin",
-            env!("CARGO_MANIFEST_DIR")
-        );
-        let tmp = tempfile::tempdir().unwrap();
-        let index_dir = tmp.path().join("index");
-        std::fs::create_dir_all(&index_dir).unwrap();
-        let file_names: Vec<_> = (1..=4)
-            .map(|field_id| {
-                let file_name = format!("index_field{field_id}.bin");
-                std::fs::copy(&src, index_dir.join(&file_name)).unwrap();
-                file_name
-            })
-            .collect();
-
-        let table_path = format!("file://{}", tmp.path().display());
-        let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let fields: Vec<_> = (0..4)
-            .map(|index| {
-                let field_id = index + 1;
-                DataField::new(
-                    field_id,
-                    format!("field{field_id}"),
-                    DataType::Int(crate::spec::IntType::new()),
-                )
-            })
-            .collect();
-        let entries: Vec<_> = file_names
-            .iter()
-            .enumerate()
-            .map(|(index, file_name)| {
-                make_global_index_entry(file_name, index as i32 + 1, 0, 99, &meta)
-            })
-            .collect();
-        let predicate = Predicate::and(
-            (0..4)
-                .map(|index| int_eq(&format!("field{}", index + 1), index, 50))
-                .collect(),
-        );
-
-        for (thread_num, expected_peak) in [(1, 1), (2, 2)] {
-            let mut scanner = GlobalIndexScanner::create(
-                &file_io,
-                &table_path,
-                thread_num,
-                i64::MAX,
-                i64::MAX,
-                &entries,
-                &fields,
-            )
-            .unwrap()
-            .unwrap();
-            let probe = Arc::new(QueryIoProbe::default());
-            scanner.query_io_probe = Some(Arc::clone(&probe));
-
-            let result = scanner.evaluate(&predicate).await.unwrap().unwrap();
-
-            assert_eq!(result.row_ranges, vec![RowRange::new(25, 25)]);
-            assert_eq!(result.evaluated_field_ids, HashSet::from([1, 2, 3, 4]));
-            assert_eq!(probe.peak(), expected_peak);
-        }
-    }
-
-    /// Regression for the Between+remaining bug in `evaluate_leaf`. When a
-    /// native `Between` leaf is paired with another conjunct (e.g. `id >= 0`),
-    /// and the file's b-tree key range falls **outside** the Between range
-    /// but is still matched by the remaining predicate, the whole AND must
-    /// produce zero rows. Before the fix, `file_result` was initialized from
-    /// the remaining predicate's bitmap and the Between conjunct was silently
-    /// dropped — the test would observe the file's full row id set instead of
-    /// the empty set.
-    #[tokio::test]
-    async fn test_between_unmatched_file_drops_remaining_match() {
-        let (file_io, table_path, file_name, _tmp) =
-            setup_testdata_table("btree_int_100_no_compress.bin");
-        // File covers keys [0, 198] (row_ids 0..99). Pick a Between range
-        // entirely below 0 so `may_match_between` is false, and a `>= 0`
-        // conjunct that would otherwise scoop up every row in the file.
-        let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
-        let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
-        let fields = int_schema_fields();
-
-        let predicates = vec![Predicate::and(vec![
-            Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: DataType::Int(crate::spec::IntType::new()),
-                op: PredicateOperator::Between,
-                literals: vec![Datum::Int(-100), Datum::Int(-50)],
-            },
-            Predicate::Leaf {
-                column: "id".to_string(),
-                index: 0,
-                data_type: DataType::Int(crate::spec::IntType::new()),
-                op: PredicateOperator::GtEq,
-                literals: vec![Datum::Int(0)],
-            },
-        ])];
-
-        let result =
-            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
-                .await
-                .unwrap();
-        let ranges = result.unwrap();
-        assert!(
-            ranges.is_empty(),
-            "Between(-100..-50) AND id>=0 must produce zero rows on a file \
-             whose key range is [0, 198] — got {ranges:?}"
-        );
-    }
-}
+mod tests;
diff --git a/crates/paimon/src/table/global_index_scanner/deletion_vectors.rs b/crates/paimon/src/table/global_index_scanner/deletion_vectors.rs
new file mode 100644
index 00000000..3fc85a83
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/deletion_vectors.rs
@@ -0,0 +1,122 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Resolution of deletion-vector positions into global row ranges.
+
+use super::{DELETION_VECTORS_INDEX_TYPE, INDEX_DIR};
+use crate::deletion_vector::DeletionVectorFactory;
+use crate::spec::{FileKind, IndexManifestEntry};
+use crate::table::{merge_row_ranges, DeletionFile, RowRange, Table};
+use crate::Result;
+use std::collections::HashMap;
+
+/// Resolve live deletion-vector index entries into global row-id ranges.
+///
+/// Data-evolution DV entries are keyed by the normal anchor data file. The DV
+/// bitmap positions are local to that anchor file's `first_row_id`, so this
+/// helper joins index metadata with live data-file metadata before converting
+/// deleted positions to global row IDs.
+pub(crate) async fn deleted_row_ranges_for_data_evolution_dvs(
+    table: &Table,
+    index_entries: &[IndexManifestEntry],
+) -> Result<Vec<RowRange>> {
+    if !index_entries.iter().any(|entry| {
+        entry.kind == FileKind::Add && entry.index_file.index_type == DELETION_VECTORS_INDEX_TYPE
+    }) {
+        return Ok(Vec::new());
+    }
+
+    let plan = table
+        .new_read_builder()
+        .new_scan()
+        .with_scan_all_files()
+        .plan()
+        .await?;
+
+    let mut first_row_ids: HashMap<(Vec<u8>, i32, String), i64> = HashMap::new();
+    for split in plan.splits() {
+        let partition = split.partition().to_serialized_bytes();
+        let bucket = split.bucket();
+        for file in split.data_files() {
+            if let Some(first_row_id) = file.first_row_id {
+                first_row_ids.insert(
+                    (partition.clone(), bucket, file.file_name.clone()),
+                    first_row_id,
+                );
+            }
+        }
+    }
+
+    let mut ranges = Vec::new();
+    let table_path = table.location().trim_end_matches('/');
+    for entry in index_entries {
+        if entry.kind != FileKind::Add || entry.index_file.index_type != DELETION_VECTORS_INDEX_TYPE
+        {
+            continue;
+        }
+        let Some(dv_ranges) = entry.index_file.deletion_vectors_ranges.as_ref() else {
+            continue;
+        };
+        let index_path = format!("{table_path}/{INDEX_DIR}/{}", entry.index_file.file_name);
+        for (data_file_name, meta) in dv_ranges {
+            let key = (
+                entry.partition.clone(),
+                entry.bucket,
+                data_file_name.clone(),
+            );
+            let first_row_id = first_row_ids.get(&key).copied().ok_or_else(|| {
+                crate::Error::DataInvalid {
+                    message: format!(
+                        "Deletion vector references data file '{}' but no live row-tracked file was found",
+                        data_file_name
+                    ),
+                    source: None,
+                }
+            })?;
+            let deletion_file = DeletionFile::new(
+                index_path.clone(),
+                meta.offset as i64,
+                meta.length as i64,
+                meta.cardinality,
+            );
+            let deletion_vector =
+                DeletionVectorFactory::read(table.file_io(), &deletion_file).await?;
+            for deleted in deletion_vector.iter() {
+                let deleted = i64::try_from(deleted).map_err(|_| crate::Error::DataInvalid {
+                    message: format!(
+                        "Deleted position {deleted} for data file '{}' exceeds i64::MAX",
+                        data_file_name
+                    ),
+                    source: None,
+                })?;
+                let row_id =
+                    first_row_id
+                        .checked_add(deleted)
+                        .ok_or_else(|| crate::Error::DataInvalid {
+                            message: format!(
+                                "Deleted row id overflows i64 for data file '{}'",
+                                data_file_name
+                            ),
+                            source: None,
+                        })?;
+                ranges.push(RowRange::new(row_id, row_id));
+            }
+        }
+    }
+
+    Ok(merge_row_ranges(ranges))
+}
diff --git a/crates/paimon/src/table/global_index_scanner/entry.rs b/crates/paimon/src/table/global_index_scanner/entry.rs
new file mode 100644
index 00000000..dd32f4f8
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/entry.rs
@@ -0,0 +1,212 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Manifest entry parsing and file-level pruning metadata.
+
+use crate::btree::BTreeIndexMeta;
+use crate::spec::{DataType, PredicateOperator};
+use crate::table::bitmap_global_index_format::is_bitmap_floating_residual_sensitive_op;
+use crate::{Error, Result};
+use std::cmp::Ordering;
+use std::collections::HashMap;
+
+/// A resolved global index entry with parsed metadata.
+pub(super) struct GlobalIndexEntry {
+    pub(super) file_name: String,
+    pub(super) index_type: GlobalIndexFileKind,
+    pub(super) file_size: i64,
+    pub(super) row_range_start: i64,
+    pub(super) row_range_end: i64,
+    pub(super) meta: GlobalIndexEntryMeta,
+}
+
+pub(super) fn sorted_entry_meta(entry: &GlobalIndexEntry) -> &BTreeIndexMeta {
+    match &entry.meta {
+        GlobalIndexEntryMeta::Sorted(meta) => meta,
+        GlobalIndexEntryMeta::FM { .. } => unreachable!("FM entries do not have sorted metadata"),
+    }
+}
+
+pub(super) enum GlobalIndexEntryMeta {
+    Sorted(BTreeIndexMeta),
+    FM {
+        bytes: Vec<u8>,
+        first_row_id: u64,
+        row_count: u64,
+    },
+}
+
+struct FMFileRowRange<'a> {
+    file_name: &'a str,
+    first_row_id: u64,
+    row_count: u64,
+}
+
+pub(super) fn validate_fm_file_sets(
+    entries_by_field: &HashMap<i32, Vec<GlobalIndexEntry>>,
+) -> Result<()> {
+    for (field_id, entries) in entries_by_field {
+        let mut groups: HashMap<(i64, i64), Vec<FMFileRowRange<'_>>> = HashMap::new();
+        for entry in entries {
+            let GlobalIndexEntryMeta::FM {
+                first_row_id,
+                row_count,
+                ..
+            } = &entry.meta
+            else {
+                continue;
+            };
+            groups
+                .entry((entry.row_range_start, entry.row_range_end))
+                .or_default()
+                .push(FMFileRowRange {
+                    file_name: &entry.file_name,
+                    first_row_id: *first_row_id,
+                    row_count: *row_count,
+                });
+        }
+
+        for ((range_start, range_end), mut files) in groups {
+            let expected_row_count = range_end
+                .checked_sub(range_start)
+                .and_then(|count| count.checked_add(1))
+                .and_then(|count| u64::try_from(count).ok())
+                .ok_or_else(|| Error::DataInvalid {
+                    message: format!(
+                        "Invalid FM global index source row range [{range_start}, {range_end}] for field {field_id}"
+                    ),
+                    source: None,
+                })?;
+            files.sort_unstable_by_key(|file| file.first_row_id);
+            let mut expected_first_row_id = 0u64;
+            for file in files {
+                let FMFileRowRange {
+                    file_name,
+                    first_row_id,
+                    row_count,
+                } = file;
+                if row_count == 0 || first_row_id != expected_first_row_id {
+                    return Err(Error::DataInvalid {
+                        message: format!(
+                            "FM global index files do not exactly cover source row range [{range_start}, {range_end}] for field {field_id}: expected relative row {expected_first_row_id}, file '{file_name}' starts at {first_row_id}"
+                        ),
+                        source: None,
+                    });
+                }
+                expected_first_row_id =
+                    first_row_id
+                        .checked_add(row_count)
+                        .ok_or_else(|| Error::DataInvalid {
+                            message: format!(
+                                "FM global index row range overflows for file '{file_name}'"
+                            ),
+                            source: None,
+                        })?;
+                if expected_first_row_id > expected_row_count {
+                    return Err(Error::DataInvalid {
+                        message: format!(
+                            "FM global index file '{file_name}' extends beyond source row range [{range_start}, {range_end}]"
+                        ),
+                        source: None,
+                    });
+                }
+            }
+            if expected_first_row_id != expected_row_count {
+                return Err(Error::DataInvalid {
+                    message: format!(
+                        "FM global index files cover {expected_first_row_id} rows, expected {expected_row_count} for source row range [{range_start}, {range_end}] and field {field_id}"
+                    ),
+                    source: None,
+                });
+            }
+        }
+    }
+    Ok(())
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) enum GlobalIndexFileKind {
+    BTree,
+    Bitmap,
+    Multivalue,
+    FM,
+}
+
+pub(super) fn is_floating_point(data_type: &DataType) -> bool {
+    matches!(data_type, DataType::Float(_) | DataType::Double(_))
+}
+
+pub(super) fn bitmap_meta_may_match(
+    meta: &BTreeIndexMeta,
+    op: PredicateOperator,
+    data_type: &DataType,
+    serialized_literals: &[Vec<u8>],
+    cmp: &dyn Fn(&[u8], &[u8]) -> Ordering,
+) -> bool {
+    if is_floating_point(data_type) && is_bitmap_floating_residual_sensitive_op(op) {
+        !meta.only_nulls()
+    } else {
+        meta.may_match(op, serialized_literals, cmp)
+    }
+}
+
+pub(super) fn bitmap_meta_may_match_between(
+    meta: &BTreeIndexMeta,
+    data_type: &DataType,
+    from_key: &[u8],
+    to_key: &[u8],
+    cmp: &dyn Fn(&[u8], &[u8]) -> Ordering,
+) -> bool {
+    if is_floating_point(data_type)
+        && is_bitmap_floating_residual_sensitive_op(PredicateOperator::Between)
+    {
+        !meta.only_nulls()
+    } else {
+        meta.may_match_between(from_key, to_key, cmp)
+    }
+}
+
+pub(super) fn multivalue_meta_may_match(
+    meta: &BTreeIndexMeta,
+    op: PredicateOperator,
+    serialized_literals: &[Vec<u8>],
+    cmp: &dyn Fn(&[u8], &[u8]) -> Ordering,
+) -> bool {
+    match op {
+        PredicateOperator::ArrayContains => {
+            meta.may_match(PredicateOperator::Eq, serialized_literals, cmp)
+        }
+        PredicateOperator::ArraysOverlap => {
+            meta.may_match(PredicateOperator::In, serialized_literals, cmp)
+        }
+        PredicateOperator::ArrayContainsAll => serialized_literals.iter().all(|literal| {
+            meta.may_match(PredicateOperator::Eq, std::slice::from_ref(literal), cmp)
+        }),
+        _ => false,
+    }
+}
+
+impl GlobalIndexFileKind {
+    pub(super) fn name(self) -> &'static str {
+        match self {
+            Self::BTree => "BTree",
+            Self::Bitmap => "bitmap",
+            Self::Multivalue => "multivalue",
+            Self::FM => "FM",
+        }
+    }
+}
diff --git a/crates/paimon/src/table/global_index_scanner/evaluator.rs b/crates/paimon/src/table/global_index_scanner/evaluator.rs
new file mode 100644
index 00000000..9b2daea1
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/evaluator.rs
@@ -0,0 +1,609 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Predicate orchestration and index-query planning.
+
+use super::entry::{
+    bitmap_meta_may_match, bitmap_meta_may_match_between, multivalue_meta_may_match,
+    sorted_entry_meta, GlobalIndexEntry, GlobalIndexFileKind,
+};
+use super::predicates::{
+    entries_support_predicate, is_multivalue_predicate, is_sorted_global_index_supported_op,
+    select_entries_for_predicates,
+};
+use super::query_plan::{
+    fallback_plan_evaluates_entry, requires_fallback_scan, EntryQueryPlan, FallbackScanPlan,
+};
+#[cfg(test)]
+use super::row_ranges::unindexed_ranges_for_coverage;
+use super::row_ranges::{bitmap_to_ranges, intersect_sorted_ranges};
+use super::GlobalIndexScanner;
+use crate::btree::query::extract_between;
+use crate::btree::{make_key_comparator, serialize_datum};
+#[cfg(test)]
+use crate::spec::GlobalIndexSearchMode;
+use crate::spec::{DataType, Datum, Predicate, PredicateOperator};
+use crate::table::bitmap_global_index_format::{
+    make_bitmap_key_comparator, serialize_bitmap_datum,
+};
+use crate::table::RowRange;
+use crate::{Error, Result};
+use futures::{StreamExt, TryStreamExt};
+use roaring::RoaringTreemap;
+use std::collections::HashSet;
+use std::future::Future;
+
+type EvaluateFuture<'a> = std::pin::Pin<
+    Box<dyn std::future::Future<Output = Result<Option<GlobalIndexScanResult>>> + Send + 'a>,
+>;
+
+type PredicateTuple<'a> = (PredicateOperator, &'a [Datum], &'a DataType);
+
+pub(super) struct GlobalIndexScanResult {
+    pub(super) row_ranges: Vec<RowRange>,
+    pub(super) evaluated_field_ids: HashSet<i32>,
+    pub(super) indexed_coverage: Vec<RowRange>,
+}
+
+pub(super) async fn try_fold_bounded<T, Fut, Acc, Fold>(
+    futures: impl IntoIterator<Item = Fut>,
+    max_concurrency: usize,
+    mut accumulator: Acc,
+    mut fold: Fold,
+) -> Result<Acc>
+where
+    Fut: Future<Output = Result<T>>,
+    Fold: FnMut(&mut Acc, T),
+{
+    debug_assert!(max_concurrency > 0);
+    let stream = futures::stream::iter(futures).buffer_unordered(max_concurrency);
+    futures::pin_mut!(stream);
+    while let Some(value) = stream.try_next().await? {
+        fold(&mut accumulator, value);
+    }
+    Ok(accumulator)
+}
+
+impl GlobalIndexScanner {
+    /// Evaluate a predicate against the global indexes and return matching row ranges.
+    /// Returns `None` if the predicate cannot be evaluated by the global index.
+    pub(super) fn evaluate<'a>(&'a self, predicate: &'a Predicate) -> EvaluateFuture<'a> {
+        Box::pin(async move {
+            match predicate {
+                Predicate::Leaf {
+                    column,
+                    op,
+                    literals,
+                    data_type,
+                    ..
+                } => {
+                    if !is_sorted_global_index_supported_op(*op) {
+                        return Ok(None);
+                    }
+                    let field_id = self.find_field_id_by_name(column)?;
+                    let field_id = match field_id {
+                        Some(id) => id,
+                        None => return Ok(None),
+                    };
+                    let entries = match self.entries_for_field(field_id) {
+                        Some(e) => e,
+                        None => return Ok(None),
+                    };
+                    if !entries_support_predicate(entries, *op, literals) {
+                        return Ok(None);
+                    }
+                    let predicates = [(*op, literals.as_slice(), data_type)];
+                    let selected_entries = select_entries_for_predicates(entries, &predicates);
+                    self.evaluate_leaf(&selected_entries, &predicates)
+                        .await
+                        .map(|result| {
+                            result.map(|(row_ranges, indexed_coverage)| GlobalIndexScanResult {
+                                row_ranges,
+                                evaluated_field_ids: HashSet::from([field_id]),
+                                indexed_coverage,
+                            })
+                        })
+                }
+                Predicate::And(children) => {
+                    // Group leaf predicates by field_id to reuse readers
+                    let mut leaf_groups: std::collections::HashMap<i32, Vec<PredicateTuple<'_>>> =
+                        std::collections::HashMap::new();
+                    let mut non_leaf_children = Vec::new();
+
+                    for child in children {
+                        if let Predicate::Leaf {
+                            column,
+                            op,
+                            literals,
+                            data_type,
+                            ..
+                        } = child
+                        {
+                            if is_sorted_global_index_supported_op(*op) {
+                                if let Some(field_id) = self.find_field_id_by_name(column)? {
+                                    if self.entries_for_field(field_id).is_some_and(|entries| {
+                                        entries_support_predicate(entries, *op, literals)
+                                    }) {
+                                        leaf_groups.entry(field_id).or_default().push((
+                                            *op,
+                                            literals.as_slice(),
+                                            data_type,
+                                        ));
+                                        continue;
+                                    }
+                                }
+                            }
+                        }
+                        non_leaf_children.push(child);
+                    }
+
+                    // Evaluate independent fields concurrently while keeping predicates for the
+                    // same field together so each index file is opened only once.
+                    let mut leaf_futures = Vec::with_capacity(leaf_groups.len());
+                    for (field_id, predicates) in &leaf_groups {
+                        if let Some(entries) = self.entries_for_field(*field_id) {
+                            let field_id = *field_id;
+                            let mut selected_predicates = predicates.clone();
+                            let mut selected_entries =
+                                select_entries_for_predicates(entries, &selected_predicates);
+                            if selected_entries.is_empty() {
+                                for predicate in predicates {
+                                    let candidate_predicates = vec![*predicate];
+                                    let candidate_entries = select_entries_for_predicates(
+                                        entries,
+                                        &candidate_predicates,
+                                    );
+                                    if !candidate_entries.is_empty() {
+                                        selected_predicates = candidate_predicates;
+                                        selected_entries = candidate_entries;
+                                        break;
+                                    }
+                                }
+                            }
+                            leaf_futures.push(async move {
+                                let result = self
+                                    .evaluate_leaf(&selected_entries, &selected_predicates)
+                                    .await?;
+                                Ok((field_id, result))
+                            });
+                        }
+                    }
+                    let leaf_group_count = leaf_futures.len();
+                    let (mut row_ranges, mut indexed_coverage, mut evaluated_field_ids) =
+                        try_fold_bounded(
+                            leaf_futures,
+                            leaf_group_count.max(1),
+                            (None::<Vec<RowRange>>, None::<Vec<RowRange>>, HashSet::new()),
+                            |(row_ranges, indexed_coverage, evaluated_field_ids),
+                             (field_id, result)| {
+                                if let Some((ranges, coverage)) = result {
+                                    *row_ranges = Some(match row_ranges.take() {
+                                        None => ranges,
+                                        Some(existing) => {
+                                            intersect_sorted_ranges(&existing, &ranges)
+                                        }
+                                    });
+                                    *indexed_coverage = Some(match indexed_coverage.take() {
+                                        None => coverage,
+                                        Some(existing) => {
+                                            intersect_sorted_ranges(&existing, &coverage)
+                                        }
+                                    });
+                                    evaluated_field_ids.insert(field_id);
+                                }
+                            },
+                        )
+                        .await?;
+
+                    // Evaluate non-leaf children recursively
+                    for child in non_leaf_children {
+                        if let Some(child_result) = self.evaluate(child).await? {
+                            row_ranges = Some(match row_ranges {
+                                None => child_result.row_ranges,
+                                Some(existing) => {
+                                    intersect_sorted_ranges(&existing, &child_result.row_ranges)
+                                }
+                            });
+                            evaluated_field_ids.extend(child_result.evaluated_field_ids);
+                            indexed_coverage = Some(match indexed_coverage {
+                                None => child_result.indexed_coverage,
+                                Some(existing) => intersect_sorted_ranges(
+                                    &existing,
+                                    &child_result.indexed_coverage,
+                                ),
+                            });
+                        }
+                    }
+
+                    Ok(row_ranges.map(|row_ranges| GlobalIndexScanResult {
+                        row_ranges,
+                        evaluated_field_ids,
+                        indexed_coverage: indexed_coverage.unwrap_or_default(),
+                    }))
+                }
+                Predicate::Or(children) => {
+                    let mut all_ranges: Vec<RowRange> = Vec::new();
+                    let mut evaluated_field_ids = HashSet::new();
+                    let mut indexed_coverage: Option<Vec<RowRange>> = None;
+                    for child in children {
+                        match self.evaluate(child).await? {
+                            Some(child_result) => {
+                                all_ranges.extend(child_result.row_ranges);
+                                evaluated_field_ids.extend(child_result.evaluated_field_ids);
+                                indexed_coverage = Some(match indexed_coverage {
+                                    None => child_result.indexed_coverage,
+                                    Some(existing) => intersect_sorted_ranges(
+                                        &existing,
+                                        &child_result.indexed_coverage,
+                                    ),
+                                });
+                            }
+                            None => return Ok(None),
+                        }
+                    }
+                    let row_ranges = if all_ranges.is_empty() {
+                        Vec::new()
+                    } else {
+                        crate::table::merge_row_ranges(all_ranges)
+                    };
+                    Ok(Some(GlobalIndexScanResult {
+                        row_ranges,
+                        evaluated_field_ids,
+                        indexed_coverage: indexed_coverage.unwrap_or_default(),
+                    }))
+                }
+                _ => Ok(None),
+            }
+        })
+    }
+
+    /// Evaluate multiple predicates against the same set of index entries.
+    /// Opens each file once and evaluates all predicates, intersecting results.
+    /// Detects between patterns (GtEq/Gt + LtEq/Lt) and merges them into a single range query.
+    async fn evaluate_leaf(
+        &self,
+        entries: &[&GlobalIndexEntry],
+        predicates: &[(PredicateOperator, &[Datum], &DataType)],
+    ) -> Result<Option<(Vec<RowRange>, Vec<RowRange>)>> {
+        let normalized_predicates = predicates
+            .iter()
+            .map(|(op, literals, data_type)| {
+                let key_type = if is_multivalue_predicate(*op) {
+                    let DataType::Array(array) = data_type else {
+                        return Err(Error::DataInvalid {
+                            message: format!(
+                                "Array global-index predicate {op} requires an ARRAY field type"
+                            ),
+                            source: None,
+                        });
+                    };
+                    array.element_type()
+                } else {
+                    *data_type
+                };
+                Ok((*op, *literals, key_type))
+            })
+            .collect::<Result<Vec<_>>>()?;
+        let predicates = normalized_predicates.as_slice();
+
+        // Try to detect between pattern and split into (between, remaining)
+        let (between, remaining) = extract_between(predicates);
+
+        let effective_predicates = if between.is_some() {
+            &remaining
+        } else {
+            predicates
+        };
+
+        // Pre-compute comparators and serialized keys for file-level pruning per predicate
+        let pruning_info: Vec<_> = effective_predicates
+            .iter()
+            .map(|(op, literals, data_type)| {
+                let btree_cmp = make_key_comparator(data_type);
+                let btree_serialized = literals
+                    .iter()
+                    .map(|l| serialize_datum(l, data_type))
+                    .collect::<Vec<_>>();
+                let bitmap_cmp = make_bitmap_key_comparator(data_type);
+                let bitmap_serialized = literals
+                    .iter()
+                    .map(|l| serialize_bitmap_datum(l, data_type))
+                    .collect::<Vec<_>>();
+                (
+                    *op,
+                    *data_type,
+                    btree_cmp,
+                    btree_serialized,
+                    bitmap_cmp,
+                    bitmap_serialized,
+                )
+            })
+            .collect();
+
+        let predicate_matches: Vec<Vec<bool>> = pruning_info
+            .iter()
+            .map(
+                |(op, data_type, btree_cmp, btree_serialized, bitmap_cmp, bitmap_serialized)| {
+                    entries
+                        .iter()
+                        .map(|entry| match entry.index_type {
+                            GlobalIndexFileKind::BTree => {
+                                sorted_entry_meta(entry).may_match(*op, btree_serialized, btree_cmp)
+                            }
+                            GlobalIndexFileKind::Bitmap => bitmap_meta_may_match(
+                                sorted_entry_meta(entry),
+                                *op,
+                                data_type,
+                                bitmap_serialized,
+                                bitmap_cmp.as_ref(),
+                            ),
+                            GlobalIndexFileKind::Multivalue => multivalue_meta_may_match(
+                                sorted_entry_meta(entry),
+                                *op,
+                                bitmap_serialized,
+                                bitmap_cmp.as_ref(),
+                            ),
+                            GlobalIndexFileKind::FM => true,
+                        })
+                        .collect()
+                },
+            )
+            .collect();
+        let predicate_fallback_plans: Vec<Option<FallbackScanPlan>> = effective_predicates
+            .iter()
+            .enumerate()
+            .map(|(i, (op, _, _))| {
+                requires_fallback_scan(*op)
+                    .then(|| self.fallback_scan_plan(entries, &predicate_matches[i]))
+            })
+            .collect();
+
+        let between_matches_by_entry: Vec<bool> =
+            match between.as_ref() {
+                Some(b) => {
+                    let btree_cmp = make_key_comparator(b.data_type);
+                    let btree_from = serialize_datum(b.from, b.data_type);
+                    let btree_to = serialize_datum(b.to, b.data_type);
+                    let bitmap_cmp = make_bitmap_key_comparator(b.data_type);
+                    let bitmap_from = serialize_bitmap_datum(b.from, b.data_type);
+                    let bitmap_to = serialize_bitmap_datum(b.to, b.data_type);
+                    entries
+                        .iter()
+                        .map(|entry| match entry.index_type {
+                            GlobalIndexFileKind::BTree => sorted_entry_meta(entry)
+                                .may_match_between(&btree_from, &btree_to, &btree_cmp),
+                            GlobalIndexFileKind::Bitmap => bitmap_meta_may_match_between(
+                                sorted_entry_meta(entry),
+                                b.data_type,
+                                &bitmap_from,
+                                &bitmap_to,
+                                bitmap_cmp.as_ref(),
+                            ),
+                            GlobalIndexFileKind::Multivalue | GlobalIndexFileKind::FM => false,
+                        })
+                        .collect()
+                }
+                None => Vec::new(),
+            };
+        let between_fallback_plan = between
+            .as_ref()
+            .map(|_| self.fallback_scan_plan(entries, &between_matches_by_entry));
+
+        let mut query_plans = Vec::with_capacity(entries.len());
+        for (entry_idx, entry) in entries.iter().enumerate() {
+            // Also check if between range may match
+            let between_matches = between
+                .as_ref()
+                .is_some_and(|_| between_matches_by_entry[entry_idx]);
+            let between_evaluated_for_entry = between_fallback_plan.is_some_and(|plan| {
+                fallback_plan_evaluates_entry(plan, entry.index_type, between_matches)
+            });
+
+            // When a Between conjunct exists but the file does not overlap its
+            // range, the whole AND cannot match — drop the file regardless of
+            // how the remaining predicates evaluate. Without this guard, a file
+            // outside the Between range but matched by some remaining predicate
+            // (e.g. `BETWEEN 10 AND 20 AND id >= 0` on a file [30, 40]) would
+            // be retained because `file_result` is initialized from the
+            // remaining bitmap, silently dropping the Between conjunct.
+            if between_evaluated_for_entry && !between_matches {
+                continue;
+            }
+
+            let mut file_evaluated = between_evaluated_for_entry;
+            let mut file_cannot_match = false;
+            let mut file_has_unsupported_match =
+                between_matches && !between_evaluated_for_entry && between_fallback_plan.is_some();
+            let matching_predicates: Vec<usize> = (0..effective_predicates.len())
+                .filter(|&i| {
+                    let predicate_matches_entry = predicate_matches[i][entry_idx];
+                    let predicate_evaluated_for_entry =
+                        predicate_fallback_plans[i].is_none_or(|plan| {
+                            fallback_plan_evaluates_entry(
+                                plan,
+                                entry.index_type,
+                                predicate_matches_entry,
+                            )
+                        });
+                    if !predicate_evaluated_for_entry {
+                        file_has_unsupported_match |= predicate_matches_entry;
+                        return false;
+                    }
+                    file_evaluated = true;
+                    if !predicate_matches_entry {
+                        file_cannot_match = true;
+                        return false;
+                    }
+                    true
+                })
+                .collect();
+            if file_cannot_match {
+                continue;
+            }
+            if !file_evaluated {
+                if file_has_unsupported_match {
+                    return Ok(None);
+                }
+                continue;
+            }
+
+            query_plans.push(EntryQueryPlan {
+                entry_idx,
+                between_matches,
+                between_evaluated: between_evaluated_for_entry,
+                matching_predicates,
+            });
+        }
+
+        // Complete all pruning and fallback decisions before starting shard I/O.
+        // A later unsupported shard must fall back to the normal scan without an
+        // earlier shard racing it with an I/O or query error.
+        let data_type = between
+            .as_ref()
+            .map(|b| b.data_type)
+            .or_else(|| effective_predicates.first().map(|p| p.2))
+            .unwrap_or(predicates[0].2);
+        let between = between.as_ref();
+        let futures =
+            query_plans.into_iter().map(|plan| async move {
+                let entry = &entries[plan.entry_idx];
+                let _permit = self.query_semaphore.acquire().await.map_err(|error| {
+                    Error::UnexpectedError {
+                        message: "global-index query concurrency budget was closed".to_string(),
+                        source: Some(Box::new(error)),
+                    }
+                })?;
+                #[cfg(test)]
+                let _query_io_probe_guard = match &self.query_io_probe {
+                    Some(probe) => Some(probe.enter().await),
+                    None => None,
+                };
+                let result = self
+                    .query_entry(entry, data_type, between, &plan, effective_predicates)
+                    .await?;
+                Ok((entry.row_range_start, result))
+            });
+        let (all_row_ids, declined) = try_fold_bounded(
+            futures,
+            self.global_index_thread_num,
+            (RoaringTreemap::new(), false),
+            |(all_row_ids, declined), (row_range_start, file_result)| {
+                if file_result.declined {
+                    *declined = true;
+                    return;
+                }
+                if let Some(bitmap) = file_result.bitmap {
+                    for row_id in bitmap.iter() {
+                        all_row_ids.insert(row_id + row_range_start as u64);
+                    }
+                }
+            },
+        )
+        .await?;
+
+        if declined {
+            return Ok(None);
+        }
+
+        let coverage = crate::table::merge_row_ranges(
+            entries
+                .iter()
+                .map(|entry| RowRange::new(entry.row_range_start, entry.row_range_end))
+                .collect(),
+        );
+        Ok(Some((bitmap_to_ranges(&all_row_ids), coverage)))
+    }
+
+    fn find_field_id_by_name(&self, column: &str) -> Result<Option<i32>> {
+        Ok(crate::table::find_field_id_by_name(
+            &self.schema_fields,
+            column,
+        ))
+    }
+
+    fn entries_for_field(&self, field_id: i32) -> Option<&[GlobalIndexEntry]> {
+        self.entries_by_field
+            .iter()
+            .find(|(id, _)| *id == field_id)
+            .map(|(_, entries)| entries.as_slice())
+    }
+
+    /// Return row ranges not covered by global indexes for this predicate.
+    ///
+    /// `full` uses `[0, snapshot.next_row_id - 1]`; `detail` uses actual
+    /// data-file row ranges collected by the scan. The caller unions these
+    /// ranges with indexed matches, and the normal read filter evaluates the
+    /// predicate on the raw rows.
+    #[cfg(test)]
+    pub(super) fn unindexed_ranges(
+        &self,
+        predicate: &Predicate,
+        search_mode: GlobalIndexSearchMode,
+        next_row_id: Option<i64>,
+        data_ranges: &[RowRange],
+    ) -> Result<Vec<RowRange>> {
+        let field_ids = self.collect_field_ids(predicate)?;
+        Ok(self.unindexed_ranges_for_field_ids(&field_ids, search_mode, next_row_id, data_ranges))
+    }
+
+    #[cfg(test)]
+    fn unindexed_ranges_for_field_ids(
+        &self,
+        field_ids: &HashSet<i32>,
+        search_mode: GlobalIndexSearchMode,
+        next_row_id: Option<i64>,
+        data_ranges: &[RowRange],
+    ) -> Vec<RowRange> {
+        unindexed_ranges_for_coverage(
+            &self.coverage_by_field,
+            field_ids,
+            search_mode,
+            next_row_id,
+            data_ranges,
+        )
+    }
+
+    #[cfg(test)]
+    fn collect_field_ids(&self, predicate: &Predicate) -> Result<HashSet<i32>> {
+        let mut field_ids = HashSet::new();
+        self.collect_field_ids_inner(predicate, &mut field_ids)?;
+        Ok(field_ids)
+    }
+
+    #[cfg(test)]
+    fn collect_field_ids_inner(
+        &self,
+        predicate: &Predicate,
+        field_ids: &mut HashSet<i32>,
+    ) -> Result<()> {
+        match predicate {
+            Predicate::Leaf { column, .. } => {
+                if let Some(field_id) = self.find_field_id_by_name(column)? {
+                    field_ids.insert(field_id);
+                }
+            }
+            Predicate::And(children) | Predicate::Or(children) => {
+                for child in children {
+                    self.collect_field_ids_inner(child, field_ids)?;
+                }
+            }
+            Predicate::Not(inner) => self.collect_field_ids_inner(inner, field_ids)?,
+            Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
+        }
+        Ok(())
+    }
+}
diff --git a/crates/paimon/src/table/global_index_scanner/predicates.rs b/crates/paimon/src/table/global_index_scanner/predicates.rs
new file mode 100644
index 00000000..32f38a71
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/predicates.rs
@@ -0,0 +1,115 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Predicate capability checks and fallback-policy decisions.
+
+use super::entry::{GlobalIndexEntry, GlobalIndexFileKind};
+use crate::spec::{DataType, Datum, PredicateOperator};
+
+/// Whether the sorted global index can evaluate this operator directly.
+/// Operators that fall outside this set bypass the index and are evaluated
+/// later in the read pipeline (stats prune + parquet row filter).
+pub(super) fn is_sorted_global_index_supported_op(op: PredicateOperator) -> bool {
+    matches!(
+        op,
+        PredicateOperator::Eq
+            | PredicateOperator::NotEq
+            | PredicateOperator::Lt
+            | PredicateOperator::LtEq
+            | PredicateOperator::Gt
+            | PredicateOperator::GtEq
+            | PredicateOperator::In
+            | PredicateOperator::NotIn
+            | PredicateOperator::IsNull
+            | PredicateOperator::IsNotNull
+            | PredicateOperator::Between
+            | PredicateOperator::NotBetween
+            | PredicateOperator::StartsWith
+            | PredicateOperator::EndsWith
+            | PredicateOperator::Contains
+            | PredicateOperator::Like
+            | PredicateOperator::ArrayContains
+            | PredicateOperator::ArraysOverlap
+            | PredicateOperator::ArrayContainsAll
+    )
+}
+
+pub(super) fn is_multivalue_predicate(op: PredicateOperator) -> bool {
+    matches!(
+        op,
+        PredicateOperator::ArrayContains
+            | PredicateOperator::ArraysOverlap
+            | PredicateOperator::ArrayContainsAll
+    )
+}
+
+pub(super) fn entry_supports_predicate(
+    entry: &GlobalIndexEntry,
+    op: PredicateOperator,
+    literals: &[Datum],
+) -> bool {
+    match entry.index_type {
+        GlobalIndexFileKind::Multivalue => {
+            is_multivalue_predicate(op)
+                && !(matches!(op, PredicateOperator::ArrayContainsAll) && literals.is_empty())
+        }
+        GlobalIndexFileKind::FM => {
+            matches!(op, PredicateOperator::IsNull | PredicateOperator::IsNotNull)
+                || (op == PredicateOperator::Contains && literals.len() == 1)
+        }
+        GlobalIndexFileKind::BTree | GlobalIndexFileKind::Bitmap => !is_multivalue_predicate(op),
+    }
+}
+
+pub(super) fn entries_support_predicate(
+    entries: &[GlobalIndexEntry],
+    op: PredicateOperator,
+    literals: &[Datum],
+) -> bool {
+    entries
+        .iter()
+        .any(|entry| entry_supports_predicate(entry, op, literals))
+}
+
+pub(super) fn select_entries_for_predicates<'a>(
+    entries: &'a [GlobalIndexEntry],
+    predicates: &[(PredicateOperator, &[Datum], &DataType)],
+) -> Vec<&'a GlobalIndexEntry> {
+    let compatible = entries
+        .iter()
+        .filter(|entry| {
+            predicates
+                .iter()
+                .all(|(op, literals, _)| entry_supports_predicate(entry, *op, literals))
+        })
+        .collect::<Vec<_>>();
+
+    if predicates
+        .iter()
+        .any(|(op, _, _)| *op == PredicateOperator::Contains)
+        && compatible
+            .iter()
+            .any(|entry| entry.index_type == GlobalIndexFileKind::FM)
+    {
+        compatible
+            .into_iter()
+            .filter(|entry| entry.index_type == GlobalIndexFileKind::FM)
+            .collect()
+    } else {
+        compatible
+    }
+}
diff --git a/crates/paimon/src/table/global_index_scanner/query_plan.rs b/crates/paimon/src/table/global_index_scanner/query_plan.rs
new file mode 100644
index 00000000..2ff579ff
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/query_plan.rs
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Per-entry query plans and fallback-scan policy.
+
+use super::entry::GlobalIndexFileKind;
+use crate::spec::PredicateOperator;
+use roaring::RoaringTreemap;
+
+#[derive(Clone, Copy, Default)]
+pub(super) struct FallbackScanPlan {
+    pub(super) selected_btree: usize,
+    pub(super) selected_bitmap: usize,
+    pub(super) allow_btree: bool,
+    pub(super) allow_bitmap: bool,
+}
+
+pub(super) struct EntryQueryPlan {
+    pub(super) entry_idx: usize,
+    pub(super) between_matches: bool,
+    pub(super) between_evaluated: bool,
+    pub(super) matching_predicates: Vec<usize>,
+}
+
+pub(super) struct EntryQueryResult {
+    pub(super) bitmap: Option<RoaringTreemap>,
+    pub(super) declined: bool,
+}
+
+impl FallbackScanPlan {
+    pub(super) fn allowed(self, kind: GlobalIndexFileKind) -> bool {
+        match kind {
+            GlobalIndexFileKind::BTree => self.allow_btree,
+            GlobalIndexFileKind::Bitmap | GlobalIndexFileKind::Multivalue => self.allow_bitmap,
+            GlobalIndexFileKind::FM => true,
+        }
+    }
+}
+
+pub(super) fn requires_fallback_scan(op: PredicateOperator) -> bool {
+    matches!(
+        op,
+        PredicateOperator::Lt
+            | PredicateOperator::LtEq
+            | PredicateOperator::Gt
+            | PredicateOperator::GtEq
+            | PredicateOperator::Between
+            | PredicateOperator::NotBetween
+            | PredicateOperator::EndsWith
+            | PredicateOperator::Contains
+            | PredicateOperator::Like
+    )
+}
+
+pub(super) fn fallback_plan_evaluates_entry(
+    plan: FallbackScanPlan,
+    kind: GlobalIndexFileKind,
+    selected: bool,
+) -> bool {
+    !selected || plan.allowed(kind)
+}
+
+pub(super) fn add_file_size(total: &mut i64, file_size: i64) -> bool {
+    if file_size < 0 {
+        return false;
+    }
+    match total.checked_add(file_size) {
+        Some(next) => {
+            *total = next;
+            true
+        }
+        None => false,
+    }
+}
diff --git a/crates/paimon/src/table/global_index_scanner/reader.rs b/crates/paimon/src/table/global_index_scanner/reader.rs
new file mode 100644
index 00000000..acc576e9
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/reader.rs
@@ -0,0 +1,380 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Type-specific index readers and shard I/O execution.
+
+use super::entry::{
+    sorted_entry_meta, GlobalIndexEntry, GlobalIndexEntryMeta, GlobalIndexFileKind,
+};
+use super::query_plan::{add_file_size, EntryQueryPlan, EntryQueryResult, FallbackScanPlan};
+use super::{BoxedCmp, GlobalIndexScanner, INDEX_DIR};
+use crate::btree::query::{BetweenInfo, IndexQuery};
+use crate::btree::{make_key_comparator, serialize_datum, BTreeIndexMeta, BTreeIndexReader};
+use crate::fm_index::FMGlobalIndexReader;
+use crate::spec::{DataType, Datum, PredicateOperator};
+use crate::table::bitmap_global_index_format::serialize_bitmap_datum;
+use crate::table::bitmap_global_index_reader::BitmapGlobalIndexReader;
+use crate::{Error, Result};
+use roaring::RoaringTreemap;
+use std::sync::Arc;
+
+pub(super) enum OpenedGlobalIndexReader {
+    BTree(BTreeIndexReader<BoxedCmp>),
+    Bitmap(BitmapGlobalIndexReader),
+    FM(FMGlobalIndexReader),
+}
+
+impl OpenedGlobalIndexReader {
+    async fn query(
+        &self,
+        op: PredicateOperator,
+        literals: &[Datum],
+        data_type: &DataType,
+    ) -> std::io::Result<Option<RoaringTreemap>> {
+        match self {
+            Self::BTree(reader) => reader.query(op, literals, data_type).await.map(Some),
+            Self::Bitmap(reader) => reader.query(op, literals, data_type).await.map(Some),
+            Self::FM(reader) => match op {
+                PredicateOperator::Contains => {
+                    let literal = literals.first().ok_or_else(|| {
+                        std::io::Error::new(
+                            std::io::ErrorKind::InvalidInput,
+                            "FM contains requires one literal",
+                        )
+                    })?;
+                    reader
+                        .contains(&serialize_bitmap_datum(literal, data_type))
+                        .await
+                }
+                PredicateOperator::IsNull => reader.is_null().await.map(Some),
+                PredicateOperator::IsNotNull => reader.is_not_null().await.map(Some),
+                _ => Ok(None),
+            },
+        }
+    }
+
+    async fn range_query(
+        &self,
+        from: &[u8],
+        to: &[u8],
+        data_type: &DataType,
+        from_inclusive: bool,
+        to_inclusive: bool,
+    ) -> std::io::Result<RoaringTreemap> {
+        match self {
+            Self::BTree(reader) => {
+                reader
+                    .range_query(from, to, from_inclusive, to_inclusive)
+                    .await
+            }
+            Self::Bitmap(reader) => {
+                reader
+                    .range_query(from, to, data_type, from_inclusive, to_inclusive)
+                    .await
+            }
+            Self::FM(_) => Err(std::io::Error::new(
+                std::io::ErrorKind::InvalidInput,
+                "FM index does not support ordered range queries",
+            )),
+        }
+    }
+}
+
+impl GlobalIndexScanner {
+    pub(super) async fn query_entry(
+        &self,
+        entry: &GlobalIndexEntry,
+        data_type: &DataType,
+        between: Option<&BetweenInfo<'_>>,
+        plan: &EntryQueryPlan,
+        effective_predicates: &[(PredicateOperator, &[Datum], &DataType)],
+    ) -> Result<EntryQueryResult> {
+        let mut reader = if (plan.between_matches && plan.between_evaluated)
+            || !plan.matching_predicates.is_empty()
+        {
+            Some(self.open_reader_for_entry(entry, data_type).await?)
+        } else {
+            None
+        };
+        let mut file_result = None;
+
+        if plan.between_matches && plan.between_evaluated {
+            let between = between.expect("evaluated between query is present");
+            let serialize_key = match entry.index_type {
+                GlobalIndexFileKind::BTree => serialize_datum,
+                GlobalIndexFileKind::Bitmap | GlobalIndexFileKind::Multivalue => {
+                    serialize_bitmap_datum
+                }
+                GlobalIndexFileKind::FM => unreachable!("FM range query was rejected in planning"),
+            };
+            let from_key = serialize_key(between.from, between.data_type);
+            let to_key = serialize_key(between.to, between.data_type);
+            let bitmap = reader
+                .as_ref()
+                .expect("reader is opened when between matches")
+                .range_query(
+                    &from_key,
+                    &to_key,
+                    between.data_type,
+                    between.from_inclusive,
+                    between.to_inclusive,
+                )
+                .await
+                .map_err(|error| Self::query_error(entry, error))?;
+            file_result = Some(bitmap);
+        }
+
+        for &idx in &plan.matching_predicates {
+            let (op, literals, data_type) = &effective_predicates[idx];
+            let Some(bitmap) = reader
+                .as_ref()
+                .expect("reader is opened when predicates match")
+                .query(*op, literals, data_type)
+                .await
+                .map_err(|error| Self::query_error(entry, error))?
+            else {
+                return Ok(EntryQueryResult {
+                    bitmap: None,
+                    declined: true,
+                });
+            };
+            file_result = Some(match file_result {
+                None => bitmap,
+                Some(mut existing) => {
+                    existing &= bitmap;
+                    existing
+                }
+            });
+        }
+
+        // Each concurrent task owns its reader. Only return it to the shared
+        // cache after all predicates for this shard have completed.
+        if let Some(OpenedGlobalIndexReader::BTree(reader)) = reader.take() {
+            self.return_reader(entry.file_name.clone(), reader);
+        }
+        Ok(EntryQueryResult {
+            bitmap: file_result,
+            declined: false,
+        })
+    }
+
+    fn query_error(entry: &GlobalIndexEntry, error: std::io::Error) -> Error {
+        Error::DataInvalid {
+            message: format!(
+                "Global index query failed for {} file '{}'",
+                entry.index_type.name(),
+                entry.file_name
+            ),
+            source: Some(Box::new(error)),
+        }
+    }
+
+    /// Get a cached reader or open a new one for the given file.
+    async fn get_or_open_reader(
+        &self,
+        entry: &GlobalIndexEntry,
+        meta: &BTreeIndexMeta,
+        data_type: &DataType,
+    ) -> Result<OpenedGlobalIndexReader> {
+        // Try to take from cache
+        {
+            let mut cache = self.reader_cache.lock().unwrap();
+            if let Some(reader) = cache.remove(&entry.file_name) {
+                return Ok(OpenedGlobalIndexReader::BTree(reader));
+            }
+        }
+
+        // Open new reader
+        let path = format!("{}/{INDEX_DIR}/{}", self.table_path, entry.file_name);
+        let input = self.file_io.new_input(&path)?;
+        let file_size = if entry.file_size > 0 {
+            entry.file_size as u64
+        } else {
+            input.metadata().await?.size
+        };
+        let file_reader = input.reader().await?;
+
+        let cmp = make_key_comparator(data_type);
+        BTreeIndexReader::open(Box::new(file_reader), file_size, meta, cmp)
+            .await
+            .map(OpenedGlobalIndexReader::BTree)
+            .map_err(|e| crate::Error::DataInvalid {
+                message: format!("Failed to open BTree index file: {}", entry.file_name),
+                source: Some(Box::new(e)),
+            })
+    }
+
+    async fn open_reader_for_entry(
+        &self,
+        entry: &GlobalIndexEntry,
+        data_type: &DataType,
+    ) -> Result<OpenedGlobalIndexReader> {
+        match entry.index_type {
+            GlobalIndexFileKind::BTree => {
+                self.get_or_open_reader(entry, sorted_entry_meta(entry), data_type)
+                    .await
+            }
+            GlobalIndexFileKind::Bitmap => self
+                .open_bitmap_reader(entry)
+                .await
+                .map(OpenedGlobalIndexReader::Bitmap)
+                .map_err(|e| crate::Error::DataInvalid {
+                    message: format!(
+                        "Failed to open bitmap global index file: {}",
+                        entry.file_name
+                    ),
+                    source: Some(Box::new(e)),
+                }),
+            GlobalIndexFileKind::Multivalue => self
+                .open_bitmap_reader(entry)
+                .await
+                .map(OpenedGlobalIndexReader::Bitmap)
+                .map_err(|e| crate::Error::DataInvalid {
+                    message: format!(
+                        "Failed to open multivalue global index file: {}",
+                        entry.file_name
+                    ),
+                    source: Some(Box::new(e)),
+                }),
+            GlobalIndexFileKind::FM => self
+                .open_fm_reader(entry)
+                .await
+                .map(OpenedGlobalIndexReader::FM)
+                .map_err(|e| crate::Error::DataInvalid {
+                    message: format!("Failed to open FM global index file: {}", entry.file_name),
+                    source: Some(Box::new(e)),
+                }),
+        }
+    }
+
+    async fn open_fm_reader(
+        &self,
+        entry: &GlobalIndexEntry,
+    ) -> std::io::Result<FMGlobalIndexReader> {
+        let GlobalIndexEntryMeta::FM {
+            bytes: manifest_meta,
+            ..
+        } = &entry.meta
+        else {
+            return Err(std::io::Error::new(
+                std::io::ErrorKind::InvalidData,
+                "FM entry has non-FM manifest metadata",
+            ));
+        };
+        let path = format!("{}/{INDEX_DIR}/{}", self.table_path, entry.file_name);
+        let input = self
+            .file_io
+            .new_input(&path)
+            .map_err(|e| std::io::Error::other(e.to_string()))?;
+        let file_size = if entry.file_size > 0 {
+            entry.file_size as u64
+        } else {
+            input
+                .metadata()
+                .await
+                .map_err(|e| std::io::Error::other(e.to_string()))?
+                .size
+        };
+        let file_reader = input
+            .reader()
+            .await
+            .map_err(|e| std::io::Error::other(e.to_string()))?;
+        FMGlobalIndexReader::open_with_context(
+            Box::new(file_reader),
+            file_size,
+            manifest_meta,
+            self.fm_read_options,
+            Arc::clone(&self.fm_read_context),
+            entry.file_name.clone(),
+        )
+        .await
+    }
+
+    async fn open_bitmap_reader(
+        &self,
+        entry: &GlobalIndexEntry,
+    ) -> std::io::Result<BitmapGlobalIndexReader> {
+        let path = format!("{}/{INDEX_DIR}/{}", self.table_path, entry.file_name);
+        let input = self
+            .file_io
+            .new_input(&path)
+            .map_err(|e| std::io::Error::other(e.to_string()))?;
+        let file_size = if entry.file_size > 0 {
+            entry.file_size as u64
+        } else {
+            input
+                .metadata()
+                .await
+                .map_err(|e| std::io::Error::other(e.to_string()))?
+                .size
+        };
+        let file_reader = input
+            .reader()
+            .await
+            .map_err(|e| std::io::Error::other(e.to_string()))?;
+        BitmapGlobalIndexReader::open(Box::new(file_reader), file_size).await
+    }
+
+    pub(super) fn fallback_scan_plan(
+        &self,
+        entries: &[&GlobalIndexEntry],
+        selected: &[bool],
+    ) -> FallbackScanPlan {
+        let mut plan = FallbackScanPlan::default();
+        let mut btree_total = 0i64;
+        let mut bitmap_total = 0i64;
+        let mut btree_valid = true;
+        let mut bitmap_valid = true;
+
+        for (entry, selected) in entries.iter().zip(selected) {
+            if !selected {
+                continue;
+            }
+            match entry.index_type {
+                GlobalIndexFileKind::BTree => {
+                    plan.selected_btree += 1;
+                    btree_valid &= add_file_size(&mut btree_total, entry.file_size);
+                }
+                GlobalIndexFileKind::Bitmap => {
+                    plan.selected_bitmap += 1;
+                    bitmap_valid &= add_file_size(&mut bitmap_total, entry.file_size);
+                }
+                GlobalIndexFileKind::Multivalue => {
+                    plan.selected_bitmap += 1;
+                    bitmap_valid &= add_file_size(&mut bitmap_total, entry.file_size);
+                }
+                GlobalIndexFileKind::FM => {}
+            }
+        }
+
+        plan.allow_btree = plan.selected_btree > 0
+            && btree_valid
+            && self.btree_fallback_scan_max_size > 0
+            && btree_total <= self.btree_fallback_scan_max_size;
+        plan.allow_bitmap = plan.selected_bitmap > 0
+            && bitmap_valid
+            && self.bitmap_fallback_scan_max_size > 0
+            && bitmap_total <= self.bitmap_fallback_scan_max_size;
+        plan
+    }
+
+    /// Return a reader to the cache for future reuse.
+    fn return_reader(&self, file_name: String, reader: BTreeIndexReader<BoxedCmp>) {
+        let mut cache = self.reader_cache.lock().unwrap();
+        cache.insert(file_name, reader);
+    }
+}
diff --git a/crates/paimon/src/table/global_index_scanner/row_ranges.rs b/crates/paimon/src/table/global_index_scanner/row_ranges.rs
new file mode 100644
index 00000000..abfe5df7
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/row_ranges.rs
@@ -0,0 +1,296 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Row-range conversion, coverage accounting, and lookup utilities.
+
+use crate::spec::{FileKind, GlobalIndexSearchMode, IndexFileMeta, IndexManifestEntry};
+use crate::table::{merge_row_ranges, source, RowRange};
+use roaring::RoaringTreemap;
+use std::collections::{HashMap, HashSet};
+
+/// Convert a RoaringTreemap to merged RowRanges (already sorted and deduplicated).
+pub(super) fn bitmap_to_ranges(bitmap: &RoaringTreemap) -> Vec<RowRange> {
+    if bitmap.is_empty() {
+        return Vec::new();
+    }
+    let mut ranges = Vec::new();
+    let mut iter = bitmap.iter();
+    let first = iter.next().unwrap();
+    let mut start = first as i64;
+    let mut end = start;
+
+    for id in iter {
+        let id = id as i64;
+        if id == end + 1 {
+            end = id;
+        } else {
+            ranges.push(RowRange::new(start, end));
+            start = id;
+            end = id;
+        }
+    }
+    ranges.push(RowRange::new(start, end));
+    ranges
+}
+
+/// Intersect two sorted range lists using RowRangeIndex for efficient binary search.
+pub(super) fn intersect_sorted_ranges(a: &[RowRange], b: &[RowRange]) -> Vec<RowRange> {
+    let idx = RowRangeIndex::create(a.to_vec());
+    let mut result = Vec::new();
+    for r in b {
+        result.extend(idx.intersected_ranges(r.from(), r.to()));
+    }
+    result
+}
+
+pub(super) fn data_ranges_for_search_mode(
+    search_mode: GlobalIndexSearchMode,
+    next_row_id: Option<i64>,
+    data_ranges: &[RowRange],
+) -> Option<Vec<RowRange>> {
+    match search_mode {
+        GlobalIndexSearchMode::Fast => None,
+        GlobalIndexSearchMode::Full => match next_row_id {
+            Some(next_row_id) if next_row_id > 0 => Some(vec![RowRange::new(0, next_row_id - 1)]),
+            _ => None,
+        },
+        GlobalIndexSearchMode::Detail => {
+            if data_ranges.is_empty() {
+                None
+            } else {
+                Some(data_ranges.to_vec())
+            }
+        }
+    }
+}
+
+pub(super) fn indexed_ranges_from_coverage(
+    coverage_by_field: &HashMap<i32, Vec<RowRange>>,
+    field_ids: &HashSet<i32>,
+) -> Vec<RowRange> {
+    let mut ranges: Option<Vec<RowRange>> = None;
+    for field_id in field_ids {
+        let Some(field_ranges) = coverage_by_field.get(field_id) else {
+            return Vec::new();
+        };
+        if field_ranges.is_empty() {
+            return Vec::new();
+        }
+        let field_ranges = merge_row_ranges(field_ranges.clone());
+        ranges = Some(match ranges {
+            None => field_ranges,
+            Some(existing) => intersect_sorted_ranges(&existing, &field_ranges),
+        });
+    }
+    ranges.map(merge_row_ranges).unwrap_or_default()
+}
+
+pub(super) fn unindexed_ranges_for_coverage(
+    coverage_by_field: &HashMap<i32, Vec<RowRange>>,
+    field_ids: &HashSet<i32>,
+    search_mode: GlobalIndexSearchMode,
+    next_row_id: Option<i64>,
+    data_ranges: &[RowRange],
+) -> Vec<RowRange> {
+    let Some(data_ranges) = data_ranges_for_search_mode(search_mode, next_row_id, data_ranges)
+    else {
+        return Vec::new();
+    };
+    let indexed_ranges = indexed_ranges_from_coverage(coverage_by_field, field_ids);
+    source::exclude_row_ranges(&data_ranges, &indexed_ranges)
+}
+
+pub(super) fn unindexed_ranges_for_indexed_coverage(
+    indexed_ranges: &[RowRange],
+    search_mode: GlobalIndexSearchMode,
+    next_row_id: Option<i64>,
+    data_ranges: &[RowRange],
+) -> Vec<RowRange> {
+    let Some(data_ranges) = data_ranges_for_search_mode(search_mode, next_row_id, data_ranges)
+    else {
+        return Vec::new();
+    };
+    source::exclude_row_ranges(&data_ranges, &merge_row_ranges(indexed_ranges.to_vec()))
+}
+
+/// Compute row ranges not covered by a family of global index files.
+///
+/// This mirrors Java `GlobalIndexCoverage`: `full` compares index coverage
+/// against `[0, snapshot.next_row_id - 1]`, while `detail` compares against
+/// exact data-file row ranges supplied by the caller.
+pub(crate) fn unindexed_ranges_for_global_index_entries(
+    index_entries: &[IndexManifestEntry],
+    field_ids: &HashSet<i32>,
+    search_mode: GlobalIndexSearchMode,
+    next_row_id: Option<i64>,
+    data_ranges: &[RowRange],
+    index_file_filter: impl Fn(&IndexFileMeta) -> bool,
+) -> Vec<RowRange> {
+    let mut coverage_by_field: HashMap<i32, Vec<RowRange>> = HashMap::new();
+    for entry in index_entries {
+        if entry.kind != FileKind::Add || !index_file_filter(&entry.index_file) {
+            continue;
+        }
+        let Some(global_meta) = entry.index_file.global_index_meta.as_ref() else {
+            continue;
+        };
+        let row_range = RowRange::new(global_meta.row_range_start, global_meta.row_range_end);
+        coverage_by_field
+            .entry(global_meta.index_field_id)
+            .or_default()
+            .push(row_range.clone());
+        if let Some(extra_field_ids) = global_meta.extra_field_ids.as_ref() {
+            for extra_field_id in extra_field_ids {
+                coverage_by_field
+                    .entry(*extra_field_id)
+                    .or_default()
+                    .push(row_range.clone());
+            }
+        }
+    }
+    unindexed_ranges_for_coverage(
+        &coverage_by_field,
+        field_ids,
+        search_mode,
+        next_row_id,
+        data_ranges,
+    )
+}
+
+/// Index for row ranges. Stores sorted, non-overlapping ranges and supports
+/// efficient intersection queries via binary search.
+///
+/// Reference: [org.apache.paimon.utils.RowRangeIndex](https://github.com/apache/paimon/blob/master/paimon-common/src/main/java/org/apache/paimon/utils/RowRangeIndex.java)
+#[derive(Debug, Clone)]
+pub(crate) struct RowRangeIndex {
+    ranges: Vec<RowRange>,
+    starts: Vec<i64>,
+    ends: Vec<i64>,
+}
+
+impl RowRangeIndex {
+    /// Create a new `RowRangeIndex` from a list of ranges.
+    /// Ranges are sorted and merged to eliminate overlaps.
+    pub fn create(ranges: Vec<RowRange>) -> Self {
+        let ranges = merge_row_ranges(ranges);
+        let starts: Vec<i64> = ranges.iter().map(|r| r.from()).collect();
+        let ends: Vec<i64> = ranges.iter().map(|r| r.to()).collect();
+        Self {
+            ranges,
+            starts,
+            ends,
+        }
+    }
+
+    /// Returns the underlying ranges.
+    #[cfg(test)]
+    pub fn ranges(&self) -> &[RowRange] {
+        &self.ranges
+    }
+
+    /// Returns true if the index has any range that intersects `[start, end]`.
+    pub fn intersects(&self, start: i64, end: i64) -> bool {
+        let candidate = lower_bound(&self.ends, start);
+        candidate < self.starts.len() && self.starts[candidate] <= end
+    }
+
+    /// Counts rows in this index that intersect `[start, end]`.
+    pub fn intersection_row_count(&self, start: i64, end: i64) -> usize {
+        if start > end {
+            return 0;
+        }
+        self.intersected_ranges(start, end)
+            .into_iter()
+            .fold(0usize, |total, range| {
+                let len = range.to().saturating_sub(range.from()).saturating_add(1);
+                total.saturating_add(usize::try_from(len).unwrap_or(usize::MAX))
+            })
+    }
+
+    /// Returns the sub-ranges of this index that intersect `[start, end]`,
+    /// clipped to the intersection boundaries.
+    pub fn intersected_ranges(&self, start: i64, end: i64) -> Vec<RowRange> {
+        let left = lower_bound(&self.ends, start);
+        if left >= self.ranges.len() || self.starts[left] > end {
+            return Vec::new();
+        }
+
+        let mut right = lower_bound(&self.ends, end);
+        if right >= self.ranges.len() {
+            right = self.ranges.len() - 1;
+        }
+
+        let mut result = Vec::new();
+
+        // First range: clip from the left
+        let first = &self.ranges[left];
+        result.push(RowRange::new(start.max(first.from()), end.min(first.to())));
+
+        // Middle ranges: fully included
+        if right > left + 1 {
+            for r in &self.ranges[left + 1..right] {
+                result.push(r.clone());
+            }
+        }
+
+        // Last range (if different from first): clip from the right
+        if right != left {
+            let last = &self.ranges[right];
+            if last.from() <= end {
+                result.push(RowRange::new(start.max(last.from()), end.min(last.to())));
+            }
+        }
+
+        result
+    }
+}
+
+pub(crate) fn search_limit_with_deleted_rows(
+    limit: usize,
+    row_range_start: i64,
+    row_range_end: i64,
+    deleted_rows: Option<&RowRangeIndex>,
+) -> usize {
+    let Some(range_len) = row_range_end
+        .checked_sub(row_range_start)
+        .and_then(|len| len.checked_add(1))
+        .and_then(|len| usize::try_from(len).ok())
+    else {
+        return limit;
+    };
+
+    let deleted_count = deleted_rows
+        .map(|index| index.intersection_row_count(row_range_start, row_range_end))
+        .unwrap_or(0)
+        .min(range_len);
+    limit.saturating_add(deleted_count).min(range_len)
+}
+
+/// Binary search: find the first index where `sorted[index] >= target`.
+fn lower_bound(sorted: &[i64], target: i64) -> usize {
+    let mut left = 0;
+    let mut right = sorted.len();
+    while left < right {
+        let mid = left + (right - left) / 2;
+        if sorted[mid] < target {
+            left = mid + 1;
+        } else {
+            right = mid;
+        }
+    }
+    left
+}
diff --git a/crates/paimon/src/table/global_index_scanner/tests.rs b/crates/paimon/src/table/global_index_scanner/tests.rs
new file mode 100644
index 00000000..29dba260
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/tests.rs
@@ -0,0 +1,2136 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::entry::*;
+use super::evaluator::try_fold_bounded;
+use super::predicates::*;
+use super::row_ranges::*;
+use super::*;
+use crate::btree::test_util::VecFileWrite;
+use crate::btree::{serialize_datum, BTreeIndexWriter, BlockCompressionType};
+use crate::fm_index::{FMGlobalIndexWriter, FMWriteOptions};
+use crate::spec::{DataType, Datum, IndexFileMeta, PredicateOperator};
+use crate::table::bitmap_global_index_format::{
+    make_bitmap_key_comparator, serialize_bitmap_datum,
+};
+use crate::table::bitmap_global_index_writer::BitmapGlobalIndexWriter;
+use roaring::RoaringTreemap;
+use std::collections::HashSet;
+use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
+use std::sync::Arc;
+
+#[tokio::test]
+async fn test_try_fold_bounded_respects_concurrency_limit() {
+    for limit in [1, 3] {
+        let active = Arc::new(AtomicUsize::new(0));
+        let peak = Arc::new(AtomicUsize::new(0));
+        let futures = (0..9usize).map(|value| {
+            let active = Arc::clone(&active);
+            let peak = Arc::clone(&peak);
+            async move {
+                let current = active.fetch_add(1, AtomicOrdering::SeqCst) + 1;
+                peak.fetch_max(current, AtomicOrdering::SeqCst);
+                tokio::task::yield_now().await;
+                active.fetch_sub(1, AtomicOrdering::SeqCst);
+                Ok::<_, crate::Error>(value)
+            }
+        });
+
+        let mut values = try_fold_bounded(futures, limit, Vec::new(), |values, value| {
+            values.push(value)
+        })
+        .await
+        .unwrap();
+        values.sort_unstable();
+
+        assert_eq!(values, (0..9).collect::<Vec<_>>());
+        assert_eq!(peak.load(AtomicOrdering::SeqCst), limit);
+    }
+}
+
+#[test]
+fn test_bitmap_to_ranges() {
+    assert_eq!(
+        bitmap_to_ranges(&RoaringTreemap::new()),
+        Vec::<RowRange>::new()
+    );
+
+    let mut bm = RoaringTreemap::new();
+    bm.insert(5);
+    assert_eq!(bitmap_to_ranges(&bm), vec![RowRange::new(5, 5)]);
+
+    let mut bm = RoaringTreemap::new();
+    for id in [1, 2, 3, 5, 6, 10] {
+        bm.insert(id);
+    }
+    assert_eq!(
+        bitmap_to_ranges(&bm),
+        vec![
+            RowRange::new(1, 3),
+            RowRange::new(5, 6),
+            RowRange::new(10, 10),
+        ]
+    );
+}
+
+#[test]
+fn test_intersect_sorted_ranges() {
+    let a = vec![RowRange::new(0, 10), RowRange::new(20, 30)];
+    let b = vec![RowRange::new(5, 25)];
+    let result = intersect_sorted_ranges(&a, &b);
+    assert_eq!(result, vec![RowRange::new(5, 10), RowRange::new(20, 25)]);
+}
+
+#[test]
+fn test_intersect_no_overlap() {
+    let a = vec![RowRange::new(0, 5)];
+    let b = vec![RowRange::new(10, 20)];
+    assert!(intersect_sorted_ranges(&a, &b).is_empty());
+}
+
+#[test]
+fn test_serialize_datum_int() {
+    let key = serialize_datum(&Datum::Int(42), &DataType::Int(crate::spec::IntType::new()));
+    assert_eq!(key, 42i32.to_le_bytes().to_vec());
+}
+
+#[test]
+fn test_serialize_datum_string() {
+    let key = serialize_datum(
+        &Datum::String("hello".to_string()),
+        &DataType::VarChar(crate::spec::VarCharType::new(100).unwrap()),
+    );
+    assert_eq!(key, b"hello".to_vec());
+}
+
+fn assert_bitmap_floating_meta_policy(
+    data_type: DataType,
+    min: Datum,
+    max: Datum,
+    outside: Datum,
+    nan: Datum,
+) {
+    let cmp = make_bitmap_key_comparator(&data_type);
+    let min_key = serialize_bitmap_datum(&min, &data_type);
+    let max_key = serialize_bitmap_datum(&max, &data_type);
+    let outside_key = serialize_bitmap_datum(&outside, &data_type);
+    let nan_key = serialize_bitmap_datum(&nan, &data_type);
+    let meta = BTreeIndexMeta::new(Some(min_key.clone()), Some(max_key), false);
+
+    assert!(!bitmap_meta_may_match(
+        &meta,
+        PredicateOperator::Eq,
+        &data_type,
+        std::slice::from_ref(&outside_key),
+        cmp.as_ref(),
+    ));
+    assert!(!bitmap_meta_may_match(
+        &meta,
+        PredicateOperator::In,
+        &data_type,
+        std::slice::from_ref(&outside_key),
+        cmp.as_ref(),
+    ));
+    assert!(!bitmap_meta_may_match(
+        &meta,
+        PredicateOperator::IsNull,
+        &data_type,
+        &[],
+        cmp.as_ref(),
+    ));
+    assert!(bitmap_meta_may_match(
+        &meta,
+        PredicateOperator::IsNotNull,
+        &data_type,
+        &[],
+        cmp.as_ref(),
+    ));
+
+    let nan_meta = BTreeIndexMeta::new(Some(min_key), Some(nan_key.clone()), false);
+    assert!(bitmap_meta_may_match(
+        &nan_meta,
+        PredicateOperator::Eq,
+        &data_type,
+        std::slice::from_ref(&nan_key),
+        cmp.as_ref(),
+    ));
+    assert!(bitmap_meta_may_match(
+        &nan_meta,
+        PredicateOperator::In,
+        &data_type,
+        std::slice::from_ref(&nan_key),
+        cmp.as_ref(),
+    ));
+
+    assert!(bitmap_meta_may_match(
+        &meta,
+        PredicateOperator::Gt,
+        &data_type,
+        std::slice::from_ref(&outside_key),
+        cmp.as_ref(),
+    ));
+    assert!(bitmap_meta_may_match_between(
+        &meta,
+        &data_type,
+        &outside_key,
+        &outside_key,
+        cmp.as_ref(),
+    ));
+
+    let only_nulls = BTreeIndexMeta::new(None, None, true);
+    assert!(bitmap_meta_may_match(
+        &only_nulls,
+        PredicateOperator::IsNull,
+        &data_type,
+        &[],
+        cmp.as_ref(),
+    ));
+    assert!(!bitmap_meta_may_match(
+        &only_nulls,
+        PredicateOperator::IsNotNull,
+        &data_type,
+        &[],
+        cmp.as_ref(),
+    ));
+    assert!(!bitmap_meta_may_match(
+        &only_nulls,
+        PredicateOperator::NotEq,
+        &data_type,
+        std::slice::from_ref(&outside_key),
+        cmp.as_ref(),
+    ));
+    assert!(!bitmap_meta_may_match_between(
+        &only_nulls,
+        &data_type,
+        &outside_key,
+        &outside_key,
+        cmp.as_ref(),
+    ));
+}
+
+#[test]
+fn test_bitmap_floating_meta_prunes_equality_and_fails_open_for_ranges() {
+    assert_bitmap_floating_meta_policy(
+        DataType::Float(crate::spec::FloatType::new()),
+        Datum::Float(-1.0),
+        Datum::Float(1.0),
+        Datum::Float(2.0),
+        Datum::Float(f32::NAN),
+    );
+    assert_bitmap_floating_meta_policy(
+        DataType::Double(crate::spec::DoubleType::new()),
+        Datum::Double(-1.0),
+        Datum::Double(1.0),
+        Datum::Double(2.0),
+        Datum::Double(f64::NAN),
+    );
+}
+
+#[test]
+fn test_row_range_index_merges_overlapping() {
+    let idx = RowRangeIndex::create(vec![
+        RowRange::new(0, 5),
+        RowRange::new(3, 10),
+        RowRange::new(20, 30),
+    ]);
+    assert_eq!(idx.ranges().len(), 2);
+    assert_eq!(idx.ranges()[0], RowRange::new(0, 10));
+    assert_eq!(idx.ranges()[1], RowRange::new(20, 30));
+}
+
+#[test]
+fn test_row_range_index_merges_adjacent() {
+    let idx = RowRangeIndex::create(vec![RowRange::new(0, 5), RowRange::new(6, 10)]);
+    assert_eq!(idx.ranges().len(), 1);
+    assert_eq!(idx.ranges()[0], RowRange::new(0, 10));
+}
+
+#[test]
+fn test_row_range_index_intersects() {
+    let idx = RowRangeIndex::create(vec![RowRange::new(10, 20), RowRange::new(30, 40)]);
+    assert!(idx.intersects(15, 25));
+    assert!(idx.intersects(5, 10));
+    assert!(idx.intersects(20, 30));
+    assert!(!idx.intersects(0, 9));
+    assert!(!idx.intersects(21, 29));
+    assert!(!idx.intersects(41, 50));
+}
+
+#[test]
+fn test_row_range_index_intersected_ranges() {
+    let idx = RowRangeIndex::create(vec![
+        RowRange::new(10, 20),
+        RowRange::new(30, 40),
+        RowRange::new(50, 60),
+    ]);
+    let result = idx.intersected_ranges(15, 55);
+    assert_eq!(
+        result,
+        vec![
+            RowRange::new(15, 20),
+            RowRange::new(30, 40),
+            RowRange::new(50, 55),
+        ]
+    );
+}
+
+#[test]
+fn test_row_range_index_intersection_row_count() {
+    let idx = RowRangeIndex::create(vec![
+        RowRange::new(10, 20),
+        RowRange::new(30, 40),
+        RowRange::new(50, 60),
+    ]);
+
+    assert_eq!(idx.intersection_row_count(15, 55), 23);
+    assert_eq!(idx.intersection_row_count(21, 29), 0);
+    assert_eq!(idx.intersection_row_count(55, 15), 0);
+}
+
+#[test]
+fn test_search_limit_with_deleted_rows_expands_and_caps() {
+    let idx = RowRangeIndex::create(vec![RowRange::new(2, 4), RowRange::new(8, 10)]);
+
+    assert_eq!(search_limit_with_deleted_rows(5, 0, 19, Some(&idx)), 11);
+    assert_eq!(search_limit_with_deleted_rows(18, 0, 19, Some(&idx)), 20);
+    assert_eq!(search_limit_with_deleted_rows(5, 0, 19, None), 5);
+}
+
+#[test]
+fn test_row_range_index_empty() {
+    let idx = RowRangeIndex::create(Vec::new());
+    assert!(!idx.intersects(0, 100));
+    assert!(idx.intersected_ranges(0, 100).is_empty());
+}
+
+fn le_int_key(v: i32) -> Vec<u8> {
+    v.to_le_bytes().to_vec()
+}
+
+/// Set up a temp dir with `index/{file_name}` containing the btree testdata file,
+/// and return (FileIO, table_path, file_name, _tmp_dir).
+fn setup_testdata_table(testdata_name: &str) -> (FileIO, String, String, tempfile::TempDir) {
+    let src = format!(
+        "{}/testdata/btree/{testdata_name}",
+        env!("CARGO_MANIFEST_DIR")
+    );
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    std::fs::copy(&src, index_dir.join(testdata_name)).unwrap();
+
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+    (file_io, table_path, testdata_name.to_string(), tmp)
+}
+
+type BitmapTestdataTable = (FileIO, String, String, BTreeIndexMeta, tempfile::TempDir);
+
+fn setup_bitmap_testdata_table(file_name: &str) -> BitmapTestdataTable {
+    let src = format!("{}/testdata/bitmap/{file_name}", env!("CARGO_MANIFEST_DIR"));
+    let meta_src = format!(
+        "{}/testdata/bitmap/{file_name}.meta",
+        env!("CARGO_MANIFEST_DIR")
+    );
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    std::fs::copy(&src, index_dir.join(file_name)).unwrap();
+    let meta = BTreeIndexMeta::deserialize(&std::fs::read(meta_src).unwrap()).unwrap();
+
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+    (file_io, table_path, file_name.to_string(), meta, tmp)
+}
+
+fn setup_java_bitmap_testdata_table() -> BitmapTestdataTable {
+    setup_bitmap_testdata_table("bitmap_varchar_java.index")
+}
+
+fn make_global_index_entry(
+    file_name: &str,
+    field_id: i32,
+    row_range_start: i64,
+    row_range_end: i64,
+    meta: &BTreeIndexMeta,
+) -> crate::spec::IndexManifestEntry {
+    make_global_index_entry_with_type(
+        BTREE_GLOBAL_INDEX_TYPE,
+        file_name,
+        field_id,
+        row_range_start,
+        row_range_end,
+        meta,
+    )
+}
+
+fn make_global_index_entry_with_type(
+    index_type: &str,
+    file_name: &str,
+    field_id: i32,
+    row_range_start: i64,
+    row_range_end: i64,
+    meta: &BTreeIndexMeta,
+) -> crate::spec::IndexManifestEntry {
+    use crate::spec::{GlobalIndexMeta, IndexFileMeta};
+    IndexManifestEntry {
+        version: 1,
+        kind: FileKind::Add,
+        partition: vec![],
+        bucket: 0,
+        index_file: IndexFileMeta {
+            index_type: index_type.to_string(),
+            file_name: file_name.to_string(),
+            file_size: 0,
+            row_count: 0,
+            deletion_vectors_ranges: None,
+            global_index_meta: Some(GlobalIndexMeta {
+                row_range_start,
+                row_range_end,
+                index_field_id: field_id,
+                extra_field_ids: None,
+                source_meta: None,
+                index_meta: Some(meta.serialize()),
+            }),
+        },
+    }
+}
+
+async fn make_fm_index_entry(
+    file_name: &str,
+    field_id: i32,
+    row_range_start: i64,
+    row_range_end: i64,
+    first_row_id: u64,
+    row_count: u64,
+) -> IndexManifestEntry {
+    let output = VecFileWrite::new();
+    let mut writer = FMGlobalIndexWriter::new(
+        Box::new(output.clone()),
+        FMWriteOptions {
+            compression: BlockCompressionType::None,
+            ..FMWriteOptions::default()
+        },
+    )
+    .unwrap();
+    for row_id in first_row_id..first_row_id + row_count {
+        writer.write(Some(b"value"), row_id).await.unwrap();
+    }
+    let result = writer.finish().await.unwrap();
+    IndexManifestEntry {
+        version: 1,
+        kind: FileKind::Add,
+        partition: vec![],
+        bucket: 0,
+        index_file: IndexFileMeta {
+            index_type: FM_GLOBAL_INDEX_TYPE.to_string(),
+            file_name: file_name.to_string(),
+            file_size: output.to_vec().len() as i64,
+            row_count: result.row_count as i64,
+            deletion_vectors_ranges: None,
+            global_index_meta: Some(crate::spec::GlobalIndexMeta {
+                row_range_start,
+                row_range_end,
+                index_field_id: field_id,
+                extra_field_ids: None,
+                source_meta: None,
+                index_meta: Some(result.index_meta),
+            }),
+        },
+    }
+}
+
+#[tokio::test]
+async fn test_fm_file_set_must_exactly_cover_source_range() {
+    let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let first = make_fm_index_entry("first.fm", 1, 10, 13, 0, 2).await;
+    let error = match GlobalIndexScanner::create(
+        &file_io,
+        "memory:/table",
+        1,
+        i64::MAX,
+        i64::MAX,
+        std::slice::from_ref(&first),
+        &string_schema_fields(),
+    ) {
+        Err(error) => error,
+        Ok(_) => panic!("a partial Java FM file set must fail closed"),
+    };
+    assert!(
+        matches!(error, Error::DataInvalid { message, .. } if message.contains("cover 2 rows, expected 4"))
+    );
+
+    let second = make_fm_index_entry("second.fm", 1, 10, 13, 2, 2).await;
+    assert!(GlobalIndexScanner::create(
+        &file_io,
+        "memory:/table",
+        1,
+        i64::MAX,
+        i64::MAX,
+        &[first, second],
+        &string_schema_fields(),
+    )
+    .unwrap()
+    .is_some());
+}
+
+#[test]
+fn test_mixed_fm_and_btree_select_compatible_index_family() {
+    let btree = GlobalIndexEntry {
+        file_name: "name.btree".to_string(),
+        index_type: GlobalIndexFileKind::BTree,
+        file_size: 1,
+        row_range_start: 0,
+        row_range_end: 9,
+        meta: GlobalIndexEntryMeta::Sorted(BTreeIndexMeta::new(None, None, false)),
+    };
+    let fm = GlobalIndexEntry {
+        file_name: "name.fm".to_string(),
+        index_type: GlobalIndexFileKind::FM,
+        file_size: 1,
+        row_range_start: 0,
+        row_range_end: 9,
+        meta: GlobalIndexEntryMeta::FM {
+            bytes: Vec::new(),
+            first_row_id: 0,
+            row_count: 10,
+        },
+    };
+    let entries = [btree, fm];
+    let data_type = DataType::VarChar(crate::spec::VarCharType::string_type());
+    let literal = [Datum::String("needle".to_string())];
+
+    let contains = [(PredicateOperator::Contains, literal.as_slice(), &data_type)];
+    let selected = select_entries_for_predicates(&entries, &contains);
+    assert_eq!(selected.len(), 1);
+    assert_eq!(selected[0].index_type, GlobalIndexFileKind::FM);
+
+    let equals = [(PredicateOperator::Eq, literal.as_slice(), &data_type)];
+    let selected = select_entries_for_predicates(&entries, &equals);
+    assert_eq!(selected.len(), 1);
+    assert_eq!(selected[0].index_type, GlobalIndexFileKind::BTree);
+}
+
+fn int_schema_fields() -> Vec<DataField> {
+    vec![DataField::new(
+        1,
+        "id".to_string(),
+        DataType::Int(crate::spec::IntType::new()),
+    )]
+}
+
+fn string_schema_fields() -> Vec<DataField> {
+    vec![DataField::new(
+        1,
+        "name".to_string(),
+        DataType::VarChar(crate::spec::VarCharType::string_type()),
+    )]
+}
+
+async fn evaluate_global_index_fast(
+    file_io: &FileIO,
+    table_path: &str,
+    entries: &[IndexManifestEntry],
+    predicates: &[Predicate],
+    fields: &[DataField],
+) -> Result<Option<Vec<RowRange>>> {
+    evaluate_global_index_fast_with_fallback_size(
+        file_io,
+        table_path,
+        entries,
+        predicates,
+        fields,
+        i64::MAX,
+        i64::MAX,
+    )
+    .await
+}
+
+async fn evaluate_global_index_fast_with_fallback_size(
+    file_io: &FileIO,
+    table_path: &str,
+    entries: &[IndexManifestEntry],
+    predicates: &[Predicate],
+    fields: &[DataField],
+    btree_fallback_scan_max_size: i64,
+    bitmap_fallback_scan_max_size: i64,
+) -> Result<Option<Vec<RowRange>>> {
+    super::evaluate_global_index(super::GlobalIndexEvaluation {
+        file_io,
+        table_path,
+        index_entries: entries,
+        predicates,
+        schema_fields: fields,
+        search_mode: GlobalIndexSearchMode::Fast,
+        global_index_thread_num: 32,
+        btree_fallback_scan_max_size,
+        bitmap_fallback_scan_max_size,
+        fm_read_options: FMReadOptions::default(),
+        next_row_id: None,
+        data_ranges: &[],
+    })
+    .await
+}
+
+fn two_field_schema_fields() -> Vec<DataField> {
+    vec![
+        DataField::new(
+            1,
+            "id".to_string(),
+            DataType::Int(crate::spec::IntType::new()),
+        ),
+        DataField::new(
+            2,
+            "value".to_string(),
+            DataType::Int(crate::spec::IntType::new()),
+        ),
+    ]
+}
+
+fn int_eq(column: &str, index: usize, value: i32) -> Predicate {
+    Predicate::Leaf {
+        column: column.to_string(),
+        index,
+        data_type: DataType::Int(crate::spec::IntType::new()),
+        op: PredicateOperator::Eq,
+        literals: vec![Datum::Int(value)],
+    }
+}
+
+#[test]
+fn test_unindexed_ranges_fast_mode_empty() {
+    let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let meta = BTreeIndexMeta::new(None, None, false);
+    let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
+    let fields = int_schema_fields();
+    let scanner = GlobalIndexScanner::create(
+        &file_io,
+        "memory:/t",
+        32,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &fields,
+    )
+    .expect("create scanner")
+    .expect("scanner");
+
+    let ranges = scanner
+        .unindexed_ranges(
+            &int_eq("id", 0, 7),
+            GlobalIndexSearchMode::Fast,
+            Some(100),
+            &[RowRange::new(50, 99)],
+        )
+        .unwrap();
+    assert!(ranges.is_empty());
+}
+
+#[test]
+fn test_unindexed_ranges_full_uses_snapshot_next_row_id() {
+    let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let meta = BTreeIndexMeta::new(None, None, false);
+    let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
+    let fields = int_schema_fields();
+    let scanner = GlobalIndexScanner::create(
+        &file_io,
+        "memory:/t",
+        32,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &fields,
+    )
+    .expect("create scanner")
+    .expect("scanner");
+
+    let ranges = scanner
+        .unindexed_ranges(
+            &int_eq("id", 0, 7),
+            GlobalIndexSearchMode::Full,
+            Some(100),
+            &[],
+        )
+        .unwrap();
+    assert_eq!(ranges, vec![RowRange::new(50, 99)]);
+}
+
+#[test]
+fn test_unindexed_ranges_detail_uses_data_file_ranges() {
+    let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let meta = BTreeIndexMeta::new(None, None, false);
+    let entries = vec![make_global_index_entry("idx", 1, 0, 49, &meta)];
+    let fields = int_schema_fields();
+    let scanner = GlobalIndexScanner::create(
+        &file_io,
+        "memory:/t",
+        32,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &fields,
+    )
+    .expect("create scanner")
+    .expect("scanner");
+
+    let ranges = scanner
+        .unindexed_ranges(
+            &int_eq("id", 0, 7),
+            GlobalIndexSearchMode::Detail,
+            Some(100),
+            &[
+                RowRange::new(0, 10),
+                RowRange::new(40, 60),
+                RowRange::new(80, 90),
+            ],
+        )
+        .unwrap();
+    assert_eq!(ranges, vec![RowRange::new(50, 60), RowRange::new(80, 90)]);
+}
+
+#[test]
+fn test_unindexed_ranges_uses_all_predicate_field_coverage() {
+    let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let meta = BTreeIndexMeta::new(None, None, false);
+    let entries = vec![
+        make_global_index_entry("idx_id", 1, 0, 49, &meta),
+        make_global_index_entry("idx_value", 2, 0, 99, &meta),
+    ];
+    let fields = two_field_schema_fields();
+    let scanner = GlobalIndexScanner::create(
+        &file_io,
+        "memory:/t",
+        32,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &fields,
+    )
+    .expect("create scanner")
+    .expect("scanner");
+    let predicate = Predicate::and(vec![int_eq("id", 0, 7), int_eq("value", 1, 8)]);
+
+    let ranges = scanner
+        .unindexed_ranges(&predicate, GlobalIndexSearchMode::Full, Some(100), &[])
+        .unwrap();
+    assert_eq!(ranges, vec![RowRange::new(50, 99)]);
+}
+
+#[test]
+fn test_unindexed_ranges_missing_field_coverage_reads_all_data_ranges() {
+    let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let meta = BTreeIndexMeta::new(None, None, false);
+    let entries = vec![make_global_index_entry("idx_id", 1, 0, 49, &meta)];
+    let fields = two_field_schema_fields();
+    let scanner = GlobalIndexScanner::create(
+        &file_io,
+        "memory:/t",
+        32,
+        i64::MAX,
+        i64::MAX,
+        &entries,
+        &fields,
+    )
+    .expect("create scanner")
+    .expect("scanner");
+    let predicate = Predicate::and(vec![int_eq("id", 0, 7), int_eq("value", 1, 8)]);
+
+    let ranges = scanner
+        .unindexed_ranges(&predicate, GlobalIndexSearchMode::Full, Some(100), &[])
+        .unwrap();
+    assert_eq!(ranges, vec![RowRange::new(0, 99)]);
+}
+
+#[test]
+fn test_unindexed_ranges_counts_extra_field_coverage() {
+    let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let meta = BTreeIndexMeta::new(None, None, false);
+    let mut entry = make_global_index_entry("idx_id_value", 1, 0, 99, &meta);
+    entry
+        .index_file
+        .global_index_meta
+        .as_mut()
+        .unwrap()
+        .extra_field_ids = Some(vec![2]);
+    let fields = two_field_schema_fields();
+    let scanner = GlobalIndexScanner::create(
+        &file_io,
+        "memory:/t",
+        32,
+        i64::MAX,
+        i64::MAX,
+        &[entry],
+        &fields,
+    )
+    .expect("create scanner")
+    .expect("scanner");
+
+    let ranges = scanner
+        .unindexed_ranges(
+            &int_eq("value", 1, 8),
+            GlobalIndexSearchMode::Full,
+            Some(100),
+            &[],
+        )
+        .unwrap();
+    assert!(ranges.is_empty());
+}
+
+#[tokio::test]
+async fn test_evaluate_extra_field_only_without_composite_reader_falls_back() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let mut entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
+    entry
+        .index_file
+        .global_index_meta
+        .as_mut()
+        .unwrap()
+        .extra_field_ids = Some(vec![2]);
+    let fields = two_field_schema_fields();
+    let predicates = vec![int_eq("value", 1, 50)];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &[entry], &predicates, &fields)
+        .await
+        .unwrap();
+    assert!(
+        result.is_none(),
+        "extra-field-only predicates must fall back until composite-key btree reads are supported"
+    );
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_eq() {
+    let (file_io, table_path, file_name, tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let mut entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
+    entry.index_file.file_size = std::fs::metadata(tmp.path().join("index").join(&file_name))
+        .unwrap()
+        .len() as i64;
+    let entries = vec![entry];
+    let fields = int_schema_fields();
+
+    // key=50 -> row_id=25, offset by row_range_start=0 -> global row_id=25
+    let predicates = vec![Predicate::Leaf {
+        column: "id".to_string(),
+        index: 0,
+        data_type: DataType::Int(crate::spec::IntType::new()),
+        op: PredicateOperator::Eq,
+        literals: vec![Datum::Int(50)],
+    }];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+        .await
+        .unwrap();
+    let ranges = result.unwrap();
+    assert_eq!(ranges, vec![RowRange::new(25, 25)]);
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_uses_known_file_size() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let mut entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
+    entry.index_file.file_size = 1;
+
+    let error = evaluate_global_index_fast(
+        &file_io,
+        &table_path,
+        &[entry],
+        &[int_eq("id", 0, 50)],
+        &int_schema_fields(),
+    )
+    .await
+    .expect_err("the known file size should be used without a metadata lookup");
+
+    assert!(matches!(
+        error,
+        crate::Error::DataInvalid { message, .. }
+            if message.contains("Failed to open BTree index file")
+    ));
+}
+
+#[tokio::test]
+async fn test_missing_index_meta_returns_error() {
+    let (file_io, table_path, file_name, tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let second_file_name = "btree_int_100_no_compress_2.bin";
+    std::fs::copy(
+        tmp.path().join("index").join(&file_name),
+        tmp.path().join("index").join(second_file_name),
+    )
+    .unwrap();
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let valid_entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
+    let mut invalid_entry = make_global_index_entry(second_file_name, 1, 100, 199, &meta);
+    invalid_entry
+        .index_file
+        .global_index_meta
+        .as_mut()
+        .unwrap()
+        .index_meta = None;
+
+    let error = evaluate_global_index_fast(
+        &file_io,
+        &table_path,
+        &[valid_entry, invalid_entry],
+        &[int_eq("id", 0, 50)],
+        &int_schema_fields(),
+    )
+    .await
+    .expect_err("missing sorted index metadata must fail the scan");
+
+    assert!(matches!(
+        error,
+        crate::Error::DataInvalid { message, .. }
+            if message.contains(second_file_name)
+    ));
+}
+
+#[tokio::test]
+async fn test_invalid_index_meta_returns_error() {
+    let (file_io, table_path, file_name, tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let second_file_name = "btree_int_100_no_compress_2.bin";
+    std::fs::copy(
+        tmp.path().join("index").join(&file_name),
+        tmp.path().join("index").join(second_file_name),
+    )
+    .unwrap();
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let valid_entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
+    let mut invalid_entry = make_global_index_entry(second_file_name, 1, 100, 199, &meta);
+    let mut invalid_meta = vec![0; 9];
+    invalid_meta[..4].copy_from_slice(&10i32.to_le_bytes());
+    invalid_entry
+        .index_file
+        .global_index_meta
+        .as_mut()
+        .unwrap()
+        .index_meta = Some(invalid_meta);
+
+    let error = evaluate_global_index_fast(
+        &file_io,
+        &table_path,
+        &[valid_entry, invalid_entry],
+        &[int_eq("id", 0, 50)],
+        &int_schema_fields(),
+    )
+    .await
+    .expect_err("invalid sorted index metadata must fail the scan");
+
+    assert!(matches!(
+        error,
+        crate::Error::DataInvalid {
+            message,
+            source: Some(_),
+        } if message.contains(second_file_name)
+    ));
+}
+
+#[tokio::test]
+async fn test_missing_global_index_meta_returns_error() {
+    let (file_io, table_path, file_name, tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let second_file_name = "btree_int_100_no_compress_2.bin";
+    std::fs::copy(
+        tmp.path().join("index").join(&file_name),
+        tmp.path().join("index").join(second_file_name),
+    )
+    .unwrap();
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let valid_entry = make_global_index_entry(&file_name, 1, 0, 99, &meta);
+    let mut invalid_entry = make_global_index_entry(second_file_name, 1, 100, 199, &meta);
+    invalid_entry.index_file.global_index_meta = None;
+
+    let error = evaluate_global_index_fast(
+        &file_io,
+        &table_path,
+        &[valid_entry, invalid_entry],
+        &[int_eq("id", 0, 50)],
+        &int_schema_fields(),
+    )
+    .await
+    .expect_err("missing global index metadata must fail the scan");
+
+    assert!(matches!(
+        error,
+        crate::Error::DataInvalid { message, .. }
+            if message.contains(second_file_name)
+    ));
+}
+
+#[tokio::test]
+async fn test_evaluate_java_bitmap_golden_index_eq_and_null() {
+    let data_type = DataType::VarChar(crate::spec::VarCharType::string_type());
+    let (file_io, table_path, file_name, meta, tmp) = setup_java_bitmap_testdata_table();
+    let mut entry =
+        make_global_index_entry_with_type(BITMAP_GLOBAL_INDEX_TYPE, &file_name, 1, 100, 109, &meta);
+    entry.index_file.file_size = std::fs::metadata(tmp.path().join("index").join(&file_name))
+        .unwrap()
+        .len() as i64;
+    let entries = vec![entry];
+    let fields = string_schema_fields();
+    assert_eq!(meta.first_key, Some(b"alpha".to_vec()));
+    assert_eq!(meta.last_key, Some(b"office".to_vec()));
+    assert!(meta.has_nulls);
+
+    let eq_predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type: data_type.clone(),
+        op: PredicateOperator::Eq,
+        literals: vec![Datum::String("k2".to_string())],
+    }];
+    let eq_result =
+        evaluate_global_index_fast(&file_io, &table_path, &entries, &eq_predicates, &fields)
+            .await
+            .unwrap();
+    assert_eq!(eq_result.unwrap(), vec![RowRange::new(105, 106)]);
+
+    let null_predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type,
+        op: PredicateOperator::IsNull,
+        literals: vec![],
+    }];
+    let null_result =
+        evaluate_global_index_fast(&file_io, &table_path, &entries, &null_predicates, &fields)
+            .await
+            .unwrap();
+    assert_eq!(null_result.unwrap(), vec![RowRange::new(104, 104)]);
+}
+
+async fn assert_bitmap_int_fixture(file_name: &str) {
+    let data_type = DataType::Int(crate::spec::IntType::new());
+    let (file_io, table_path, file_name, meta, _tmp) = setup_bitmap_testdata_table(file_name);
+    let entries = vec![make_global_index_entry_with_type(
+        BITMAP_GLOBAL_INDEX_TYPE,
+        &file_name,
+        1,
+        100,
+        105,
+        &meta,
+    )];
+    let fields = int_schema_fields();
+    assert_eq!(meta.first_key, Some(le_int_key(-1)));
+    assert_eq!(meta.last_key, Some(le_int_key(256)));
+    assert!(meta.has_nulls);
+
+    let cases = [
+        (
+            PredicateOperator::Eq,
+            vec![Datum::Int(0)],
+            vec![RowRange::new(101, 102)],
+        ),
+        (
+            PredicateOperator::Eq,
+            vec![Datum::Int(256)],
+            vec![RowRange::new(104, 104)],
+        ),
+        (
+            PredicateOperator::In,
+            vec![Datum::Int(-1), Datum::Int(1), Datum::Int(256)],
+            vec![RowRange::new(100, 100), RowRange::new(103, 104)],
+        ),
+        (
+            PredicateOperator::NotEq,
+            vec![Datum::Int(0)],
+            vec![RowRange::new(100, 100), RowRange::new(103, 104)],
+        ),
+        (
+            PredicateOperator::NotIn,
+            vec![Datum::Int(-1), Datum::Int(1), Datum::Int(256)],
+            vec![RowRange::new(101, 102)],
+        ),
+        (
+            PredicateOperator::IsNull,
+            vec![],
+            vec![RowRange::new(105, 105)],
+        ),
+    ];
+
+    for (op, literals, expected) in cases {
+        let predicates = vec![Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op,
+            literals,
+        }];
+        let result =
+            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+                .await
+                .unwrap()
+                .unwrap();
+        assert_eq!(result, expected, "{file_name}: {op}");
+    }
+}
+
+#[tokio::test]
+async fn test_evaluate_java_logical_order_bitmap_int_fixture() {
+    assert_bitmap_int_fixture("bitmap_int_logical_java.index").await;
+}
+
+async fn assert_bitmap_nan_equality_uses_direct_lookup(
+    data_type: DataType,
+    nan_literals: [Datum; 3],
+    zero: Datum,
+) {
+    let output = VecFileWrite::new();
+    let captured = output.clone();
+    let mut writer = BitmapGlobalIndexWriter::new(
+        Box::new(output),
+        1,
+        BlockCompressionType::None,
+        make_bitmap_key_comparator(&data_type),
+    );
+    for (row_id, literal) in nan_literals.iter().enumerate() {
+        let key = serialize_bitmap_datum(literal, &data_type);
+        writer.write(Some(&key), row_id as i64).unwrap();
+    }
+    let zero_key = serialize_bitmap_datum(&zero, &data_type);
+    writer.write(Some(&zero_key), 3).unwrap();
+    let write_result = writer.finish().await.unwrap();
+    let bytes = captured.to_vec();
+
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    let file_name = "bitmap-current.index";
+    std::fs::write(index_dir.join(file_name), &bytes).unwrap();
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+
+    let mut entry = make_global_index_entry_with_type(
+        BITMAP_GLOBAL_INDEX_TYPE,
+        file_name,
+        1,
+        100,
+        103,
+        &write_result.meta,
+    );
+    entry.index_file.file_size = bytes.len() as i64;
+    let entries = vec![entry];
+    let fields = vec![DataField::new(1, "id".to_string(), data_type.clone())];
+    let cases = [
+        (PredicateOperator::Eq, vec![nan_literals[0].clone()]),
+        (
+            PredicateOperator::In,
+            vec![nan_literals[1].clone(), nan_literals[2].clone()],
+        ),
+    ];
+
+    for (op, literals) in cases {
+        let predicates = vec![Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op,
+            literals,
+        }];
+        let result = evaluate_global_index_fast_with_fallback_size(
+            &file_io,
+            &table_path,
+            &entries,
+            &predicates,
+            &fields,
+            i64::MAX,
+            0,
+        )
+        .await
+        .unwrap()
+        .unwrap();
+        assert_eq!(result, vec![RowRange::new(100, 102)], "{data_type:?}: {op}");
+    }
+}
+
+#[tokio::test]
+async fn test_bitmap_nan_equality_uses_direct_lookup_with_fallback_scan_disabled() {
+    assert_bitmap_nan_equality_uses_direct_lookup(
+        DataType::Float(crate::spec::FloatType::new()),
+        [
+            Datum::Float(f32::from_bits(0xffc0_0001)),
+            Datum::Float(f32::from_bits(0x7fc0_0010)),
+            Datum::Float(f32::NAN),
+        ],
+        Datum::Float(0.0),
+    )
+    .await;
+    assert_bitmap_nan_equality_uses_direct_lookup(
+        DataType::Double(crate::spec::DoubleType::new()),
+        [
+            Datum::Double(f64::from_bits(0xfff8_0000_0000_0001)),
+            Datum::Double(f64::from_bits(0x7ff8_0000_0000_0010)),
+            Datum::Double(f64::NAN),
+        ],
+        Datum::Double(0.0),
+    )
+    .await;
+}
+
+fn legacy_floating_comparator(data_type: &DataType) -> BoxedCmp {
+    match data_type {
+        DataType::Float(_) => Box::new(|left, right| {
+            let left = f32::from_le_bytes(left.try_into().unwrap());
+            let right = f32::from_le_bytes(right.try_into().unwrap());
+            left.total_cmp(&right)
+        }),
+        DataType::Double(_) => Box::new(|left, right| {
+            let left = f64::from_le_bytes(left.try_into().unwrap());
+            let right = f64::from_le_bytes(right.try_into().unwrap());
+            left.total_cmp(&right)
+        }),
+        _ => unreachable!("legacy floating comparator requires Float or Double"),
+    }
+}
+
+async fn assert_legacy_floating_btree(
+    file_name: &str,
+    data_type: DataType,
+    nan_keys: Vec<Vec<u8>>,
+    nan_literals: Vec<Datum>,
+    zero_key: Vec<u8>,
+    zero_literal: Datum,
+) {
+    let mut rows = nan_keys
+        .into_iter()
+        .enumerate()
+        .map(|(row_id, key)| (key, row_id as i64))
+        .collect::<Vec<_>>();
+    rows.push((zero_key, 3));
+    let cmp = legacy_floating_comparator(&data_type);
+    rows.sort_by(|left, right| cmp(&left.0, &right.0));
+    let expected_first_key = rows.first().unwrap().0.clone();
+    let expected_last_key = rows.last().unwrap().0.clone();
+
+    let output = VecFileWrite::new();
+    let captured = output.clone();
+    let mut writer =
+        BTreeIndexWriter::with_comparator(Box::new(output), 1, BlockCompressionType::None, cmp);
+    for (key, row_id) in rows {
+        writer.write(Some(&key), row_id).await.unwrap();
+    }
+    let write_result = writer.finish().await.unwrap();
+    assert_eq!(write_result.meta.first_key, Some(expected_first_key));
+    assert_eq!(write_result.meta.last_key, Some(expected_last_key));
+
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    std::fs::write(index_dir.join(file_name), captured.to_vec()).unwrap();
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+    let entries = vec![make_global_index_entry(
+        file_name,
+        1,
+        100,
+        103,
+        &write_result.meta,
+    )];
+    let fields = vec![DataField::new(1, "id".to_string(), data_type.clone())];
+    let cases = [
+        (
+            PredicateOperator::Eq,
+            vec![zero_literal.clone()],
+            vec![RowRange::new(103, 103)],
+        ),
+        (
+            PredicateOperator::Eq,
+            vec![nan_literals[0].clone()],
+            vec![RowRange::new(100, 100)],
+        ),
+        (
+            PredicateOperator::In,
+            vec![
+                nan_literals[0].clone(),
+                nan_literals[1].clone(),
+                zero_literal,
+            ],
+            vec![RowRange::new(100, 101), RowRange::new(103, 103)],
+        ),
+    ];
+
+    for (op, literals, expected) in cases {
+        let predicates = vec![Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op,
+            literals,
+        }];
+        let result =
+            evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+                .await
+                .unwrap()
+                .unwrap();
+        assert_eq!(result, expected, "{file_name}: {op}");
+    }
+}
+
+#[tokio::test]
+async fn test_evaluate_legacy_float_btree() {
+    let nan_bits = [0xffc0_0001u32, 0xffc0_0010, 0xffff_1234];
+    assert_legacy_floating_btree(
+        "btree_float_legacy_rust.index",
+        DataType::Float(crate::spec::FloatType::new()),
+        nan_bits
+            .iter()
+            .map(|bits| bits.to_le_bytes().to_vec())
+            .collect(),
+        nan_bits
+            .iter()
+            .map(|bits| Datum::Float(f32::from_bits(*bits)))
+            .collect(),
+        0.0f32.to_le_bytes().to_vec(),
+        Datum::Float(0.0),
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_evaluate_legacy_double_btree() {
+    let nan_bits = [
+        0xfff8_0000_0000_0001u64,
+        0xfff8_0000_0000_0010,
+        0xffff_1234_5678_9abc,
+    ];
+    assert_legacy_floating_btree(
+        "btree_double_legacy_rust.index",
+        DataType::Double(crate::spec::DoubleType::new()),
+        nan_bits
+            .iter()
+            .map(|bits| bits.to_le_bytes().to_vec())
+            .collect(),
+        nan_bits
+            .iter()
+            .map(|bits| Datum::Double(f64::from_bits(*bits)))
+            .collect(),
+        0.0f64.to_le_bytes().to_vec(),
+        Datum::Double(0.0),
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn test_evaluate_java_bitmap_golden_index_string_fallback_scan() {
+    let data_type = DataType::VarChar(crate::spec::VarCharType::string_type());
+    let (file_io, table_path, file_name, meta, tmp) = setup_java_bitmap_testdata_table();
+    let file_size = std::fs::metadata(tmp.path().join("index").join(&file_name))
+        .unwrap()
+        .len() as i64;
+    let mut entry =
+        make_global_index_entry_with_type(BITMAP_GLOBAL_INDEX_TYPE, &file_name, 1, 100, 109, &meta);
+    entry.index_file.file_size = file_size;
+    let entries = vec![entry];
+    let fields = string_schema_fields();
+
+    let ends_with_predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type: data_type.clone(),
+        op: PredicateOperator::EndsWith,
+        literals: vec![Datum::String("ta".to_string())],
+    }];
+    let ends_with_result = evaluate_global_index_fast(
+        &file_io,
+        &table_path,
+        &entries,
+        &ends_with_predicates,
+        &fields,
+    )
+    .await
+    .unwrap();
+    assert_eq!(
+        ends_with_result.unwrap(),
+        vec![RowRange::new(101, 101), RowRange::new(103, 103)]
+    );
+
+    let contains_predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type: data_type.clone(),
+        op: PredicateOperator::Contains,
+        literals: vec![Datum::String("ph".to_string())],
+    }];
+    let contains_result = evaluate_global_index_fast(
+        &file_io,
+        &table_path,
+        &entries,
+        &contains_predicates,
+        &fields,
+    )
+    .await
+    .unwrap();
+    assert_eq!(
+        contains_result.unwrap(),
+        vec![RowRange::new(100, 100), RowRange::new(102, 102)]
+    );
+
+    let like_predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type: data_type.clone(),
+        op: PredicateOperator::Like,
+        literals: vec![Datum::String("%ha%".to_string())],
+    }];
+    let like_result =
+        evaluate_global_index_fast(&file_io, &table_path, &entries, &like_predicates, &fields)
+            .await
+            .unwrap();
+    assert_eq!(
+        like_result.unwrap(),
+        vec![RowRange::new(100, 100), RowRange::new(102, 102)]
+    );
+
+    let less_than_predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type: data_type.clone(),
+        op: PredicateOperator::Lt,
+        literals: vec![Datum::String("delta".to_string())],
+    }];
+    let less_than_result = evaluate_global_index_fast(
+        &file_io,
+        &table_path,
+        &entries,
+        &less_than_predicates,
+        &fields,
+    )
+    .await
+    .unwrap();
+    assert_eq!(less_than_result.unwrap(), vec![RowRange::new(100, 102)]);
+
+    let mut over_limit_entry =
+        make_global_index_entry_with_type(BITMAP_GLOBAL_INDEX_TYPE, &file_name, 1, 100, 109, &meta);
+    over_limit_entry.index_file.file_size = file_size;
+    let over_limit_entries = vec![over_limit_entry];
+    let over_limit_less_than = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        &table_path,
+        &over_limit_entries,
+        &less_than_predicates,
+        &fields,
+        i64::MAX,
+        file_size - 1,
+    )
+    .await
+    .unwrap();
+    assert!(
+        over_limit_less_than.is_none(),
+        "range predicates require fallback dictionary scans and should be unsupported over budget"
+    );
+
+    let no_match_contains = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type: data_type.clone(),
+        op: PredicateOperator::Contains,
+        literals: vec![Datum::String("zz".to_string())],
+    }];
+    let over_limit_result = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        &table_path,
+        &over_limit_entries,
+        &no_match_contains,
+        &fields,
+        i64::MAX,
+        file_size - 1,
+    )
+    .await
+    .unwrap();
+    assert!(
+        over_limit_result.is_none(),
+        "fallback scans over budget should be unsupported instead of returning full coverage"
+    );
+
+    let direct_with_over_limit_fallback = vec![Predicate::and(vec![
+        Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type: data_type.clone(),
+            op: PredicateOperator::Eq,
+            literals: vec![Datum::String("k2".to_string())],
+        },
+        Predicate::Leaf {
+            column: "name".to_string(),
+            index: 0,
+            data_type,
+            op: PredicateOperator::Contains,
+            literals: vec![Datum::String("zz".to_string())],
+        },
+    ])];
+    let direct_result = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        &table_path,
+        &over_limit_entries,
+        &direct_with_over_limit_fallback,
+        &fields,
+        i64::MAX,
+        file_size - 1,
+    )
+    .await
+    .unwrap();
+    assert_eq!(direct_result.unwrap(), vec![RowRange::new(105, 106)]);
+}
+
+#[tokio::test]
+async fn test_btree_fallback_scan_over_limit_is_unsupported() {
+    let (file_io, table_path, file_name, tmp) =
+        setup_testdata_table("btree_varchar_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(b"a".to_vec()), Some(b"yyyy".to_vec()), false);
+    let fields = string_schema_fields();
+    let data_type = DataType::VarChar(crate::spec::VarCharType::string_type());
+    let predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type,
+        op: PredicateOperator::Contains,
+        literals: vec![Datum::String("not-present".to_string())],
+    }];
+
+    let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    let exact_result = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        &table_path,
+        &entries,
+        &predicates,
+        &fields,
+        i64::MAX,
+        i64::MAX,
+    )
+    .await
+    .unwrap();
+    assert_eq!(exact_result.unwrap(), Vec::<RowRange>::new());
+
+    let mut over_limit_entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    over_limit_entries[0].index_file.file_size = 2;
+    let over_limit_result = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        &table_path,
+        &over_limit_entries,
+        &predicates,
+        &fields,
+        1,
+        i64::MAX,
+    )
+    .await
+    .unwrap();
+    assert!(
+        over_limit_result.is_none(),
+        "fallback scans over budget should be unsupported instead of returning full coverage"
+    );
+
+    let second_file_name = "btree_varchar_100_no_compress_2.bin";
+    std::fs::copy(
+        tmp.path().join("index").join(&file_name),
+        tmp.path().join("index").join(second_file_name),
+    )
+    .unwrap();
+    let mut first = make_global_index_entry(&file_name, 1, 0, 99, &meta);
+    first.index_file.file_size = 1;
+    let mut second = make_global_index_entry(second_file_name, 1, 100, 199, &meta);
+    second.index_file.file_size = 1;
+    let total_over_limit_result = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        &table_path,
+        &[first, second],
+        &predicates,
+        &fields,
+        1,
+        i64::MAX,
+    )
+    .await
+    .unwrap();
+    assert!(
+        total_over_limit_result.is_none(),
+        "fallback budget should use selected files' total size, not per-file size"
+    );
+}
+
+#[tokio::test]
+async fn test_fallback_scan_over_limit_with_mixed_index_kinds_is_unsupported() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_varchar_100_no_compress.bin");
+    let btree_meta = BTreeIndexMeta::new(Some(b"a".to_vec()), Some(b"yyyy".to_vec()), false);
+    let bitmap_meta = BTreeIndexMeta::new(Some(b"m".to_vec()), Some(b"z".to_vec()), false);
+    let fields = string_schema_fields();
+    let predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type: DataType::VarChar(crate::spec::VarCharType::string_type()),
+        op: PredicateOperator::Lt,
+        literals: vec![Datum::String("delta".to_string())],
+    }];
+
+    let mut btree = make_global_index_entry_with_type(
+        BTREE_GLOBAL_INDEX_TYPE,
+        &file_name,
+        1,
+        0,
+        99,
+        &btree_meta,
+    );
+    btree.index_file.file_size = 2;
+    let mut bitmap = make_global_index_entry_with_type(
+        BITMAP_GLOBAL_INDEX_TYPE,
+        "bitmap-no-match.index",
+        1,
+        100,
+        199,
+        &bitmap_meta,
+    );
+    bitmap.index_file.file_size = 1;
+
+    let result = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        &table_path,
+        &[btree, bitmap],
+        &predicates,
+        &fields,
+        1,
+        i64::MAX,
+    )
+    .await
+    .unwrap();
+    assert!(
+            result.is_none(),
+            "an over-budget selected BTree file must stay unsupported even if bitmap files are pruned by metadata"
+        );
+}
+
+#[tokio::test]
+async fn test_fallback_preflight_happens_before_shard_io() {
+    let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let table_path = "memory:/missing-index-files";
+    let meta = BTreeIndexMeta::new(Some(b"a".to_vec()), Some(b"z".to_vec()), false);
+    let fields = string_schema_fields();
+    let predicates = vec![Predicate::Leaf {
+        column: "name".to_string(),
+        index: 0,
+        data_type: DataType::VarChar(crate::spec::VarCharType::string_type()),
+        op: PredicateOperator::Contains,
+        literals: vec![Datum::String("middle".to_string())],
+    }];
+
+    let mut btree = make_global_index_entry_with_type(
+        BTREE_GLOBAL_INDEX_TYPE,
+        "missing-btree.index",
+        1,
+        0,
+        99,
+        &meta,
+    );
+    btree.index_file.file_size = 1;
+    let mut bitmap = make_global_index_entry_with_type(
+        BITMAP_GLOBAL_INDEX_TYPE,
+        "missing-bitmap.index",
+        1,
+        100,
+        199,
+        &meta,
+    );
+    bitmap.index_file.file_size = 1;
+
+    let result = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        table_path,
+        &[btree, bitmap],
+        &predicates,
+        &fields,
+        1,
+        0,
+    )
+    .await
+    .expect("fallback must be decided before opening an earlier shard");
+
+    assert!(result.is_none());
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_full_mode_includes_unindexed_tail() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    let fields = int_schema_fields();
+    let predicates = vec![int_eq("id", 0, 50)];
+
+    let result = super::evaluate_global_index(super::GlobalIndexEvaluation {
+        file_io: &file_io,
+        table_path: &table_path,
+        index_entries: &entries,
+        predicates: &predicates,
+        schema_fields: &fields,
+        search_mode: GlobalIndexSearchMode::Full,
+        global_index_thread_num: 32,
+        btree_fallback_scan_max_size: i64::MAX,
+        bitmap_fallback_scan_max_size: i64::MAX,
+        fm_read_options: FMReadOptions::default(),
+        next_row_id: Some(150),
+        data_ranges: &[],
+    })
+    .await
+    .unwrap();
+
+    assert_eq!(
+        result.unwrap(),
+        vec![RowRange::new(25, 25), RowRange::new(100, 149)]
+    );
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_and_uses_evaluated_field_coverage_for_raw_fallback() {
+    let src = format!(
+        "{}/testdata/btree/btree_int_100_no_compress.bin",
+        env!("CARGO_MANIFEST_DIR")
+    );
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    std::fs::copy(&src, index_dir.join("index_part1.bin")).unwrap();
+    std::fs::copy(&src, index_dir.join("index_part2.bin")).unwrap();
+
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+
+    let mut first = make_global_index_entry("index_part1.bin", 1, 0, 49, &meta);
+    first
+        .index_file
+        .global_index_meta
+        .as_mut()
+        .unwrap()
+        .extra_field_ids = Some(vec![2]);
+    let second = make_global_index_entry("index_part2.bin", 1, 50, 99, &meta);
+    let entries = vec![first, second];
+    let fields = two_field_schema_fields();
+
+    let predicates = vec![Predicate::and(vec![
+        int_eq("id", 0, 50),
+        int_eq("value", 1, 8),
+    ])];
+    let result = super::evaluate_global_index(super::GlobalIndexEvaluation {
+        file_io: &file_io,
+        table_path: &table_path,
+        index_entries: &entries,
+        predicates: &predicates,
+        schema_fields: &fields,
+        search_mode: GlobalIndexSearchMode::Full,
+        global_index_thread_num: 32,
+        btree_fallback_scan_max_size: i64::MAX,
+        bitmap_fallback_scan_max_size: i64::MAX,
+        fm_read_options: FMReadOptions::default(),
+        next_row_id: Some(100),
+        data_ranges: &[],
+    })
+    .await
+    .unwrap();
+
+    assert_eq!(
+        result.unwrap(),
+        vec![RowRange::new(25, 25), RowRange::new(75, 75)],
+        "raw fallback should use only the id field that was actually evaluated; \
+             the unevaluated extra field must not widen or narrow fallback coverage"
+    );
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_detail_mode_uses_data_ranges() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    let fields = int_schema_fields();
+    let predicates = vec![int_eq("id", 0, 50)];
+
+    let data_ranges = [RowRange::new(90, 120), RowRange::new(140, 145)];
+    let result = super::evaluate_global_index(super::GlobalIndexEvaluation {
+        file_io: &file_io,
+        table_path: &table_path,
+        index_entries: &entries,
+        predicates: &predicates,
+        schema_fields: &fields,
+        search_mode: GlobalIndexSearchMode::Detail,
+        global_index_thread_num: 32,
+        btree_fallback_scan_max_size: i64::MAX,
+        bitmap_fallback_scan_max_size: i64::MAX,
+        fm_read_options: FMReadOptions::default(),
+        next_row_id: Some(150),
+        data_ranges: &data_ranges,
+    })
+    .await
+    .unwrap();
+
+    assert_eq!(
+        result.unwrap(),
+        vec![
+            RowRange::new(25, 25),
+            RowRange::new(100, 120),
+            RowRange::new(140, 145),
+        ]
+    );
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_range() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    let fields = int_schema_fields();
+
+    // keys 10..=20 -> keys 10,12,14,16,18,20 -> row_ids 5,6,7,8,9,10
+    let predicates = vec![
+        Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: DataType::Int(crate::spec::IntType::new()),
+            op: PredicateOperator::GtEq,
+            literals: vec![Datum::Int(10)],
+        },
+        Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: DataType::Int(crate::spec::IntType::new()),
+            op: PredicateOperator::LtEq,
+            literals: vec![Datum::Int(20)],
+        },
+    ];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+        .await
+        .unwrap();
+    let ranges = result.unwrap();
+    assert_eq!(ranges, vec![RowRange::new(5, 10)]);
+
+    let mut over_limit_entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    over_limit_entries[0].index_file.file_size = 2;
+    let over_limit_result = evaluate_global_index_fast_with_fallback_size(
+        &file_io,
+        &table_path,
+        &over_limit_entries,
+        &predicates,
+        &fields,
+        1,
+        i64::MAX,
+    )
+    .await
+    .unwrap();
+    assert!(
+        over_limit_result.is_none(),
+        "between/range predicates require fallback scans and should be unsupported over budget"
+    );
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_in() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    let fields = int_schema_fields();
+
+    // IN(0, 50, 198) -> row_ids 0, 25, 99
+    let predicates = vec![Predicate::Leaf {
+        column: "id".to_string(),
+        index: 0,
+        data_type: DataType::Int(crate::spec::IntType::new()),
+        op: PredicateOperator::In,
+        literals: vec![Datum::Int(0), Datum::Int(50), Datum::Int(198)],
+    }];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+        .await
+        .unwrap();
+    let ranges = result.unwrap();
+    assert_eq!(
+        ranges,
+        vec![
+            RowRange::new(0, 0),
+            RowRange::new(25, 25),
+            RowRange::new(99, 99)
+        ]
+    );
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_no_match() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    let fields = int_schema_fields();
+
+    // key=999 doesn't exist
+    let predicates = vec![Predicate::Leaf {
+        column: "id".to_string(),
+        index: 0,
+        data_type: DataType::Int(crate::spec::IntType::new()),
+        op: PredicateOperator::Eq,
+        literals: vec![Datum::Int(999)],
+    }];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+        .await
+        .unwrap();
+    let ranges = result.unwrap();
+    assert!(ranges.is_empty());
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_with_row_offset() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    // row_range_start=1000 simulates an offset
+    let entries = vec![make_global_index_entry(&file_name, 1, 1000, 1099, &meta)];
+    let fields = int_schema_fields();
+
+    // key=50 -> local row_id=25, offset -> global row_id=1025
+    let predicates = vec![Predicate::Leaf {
+        column: "id".to_string(),
+        index: 0,
+        data_type: DataType::Int(crate::spec::IntType::new()),
+        op: PredicateOperator::Eq,
+        literals: vec![Datum::Int(50)],
+    }];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+        .await
+        .unwrap();
+    let ranges = result.unwrap();
+    assert_eq!(ranges, vec![RowRange::new(1025, 1025)]);
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_unknown_column() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    let fields = int_schema_fields();
+
+    // Column "unknown" not in schema -> None (can't evaluate)
+    let predicates = vec![Predicate::Leaf {
+        column: "unknown".to_string(),
+        index: 0,
+        data_type: DataType::Int(crate::spec::IntType::new()),
+        op: PredicateOperator::Eq,
+        literals: vec![Datum::Int(50)],
+    }];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+        .await
+        .unwrap();
+    assert!(result.is_none());
+}
+
+#[tokio::test]
+async fn test_evaluate_global_index_multi_field_and() {
+    // Two fields, each with its own btree index file (same data, different field_id).
+    // btree_int_100_no_compress.bin: keys 0,2,4,...,198 -> row_ids 0,1,...,99
+    let src = format!(
+        "{}/testdata/btree/btree_int_100_no_compress.bin",
+        env!("CARGO_MANIFEST_DIR")
+    );
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    std::fs::copy(&src, index_dir.join("index_field1.bin")).unwrap();
+    std::fs::copy(&src, index_dir.join("index_field2.bin")).unwrap();
+
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+
+    let fields = vec![
+        DataField::new(
+            1,
+            "id".to_string(),
+            DataType::Int(crate::spec::IntType::new()),
+        ),
+        DataField::new(
+            2,
+            "value".to_string(),
+            DataType::Int(crate::spec::IntType::new()),
+        ),
+    ];
+
+    let entries = vec![
+        make_global_index_entry("index_field1.bin", 1, 0, 99, &meta),
+        make_global_index_entry("index_field2.bin", 2, 0, 99, &meta),
+    ];
+
+    // id >= 40 AND id <= 60 → keys 40,42,...,60 → row_ids 20..30
+    // value >= 44 AND value <= 52 → keys 44,46,48,50,52 → row_ids 22..26
+    // AND → intersect [20..30] and [22..26] = [22..26]
+    let predicates = vec![
+        Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: DataType::Int(crate::spec::IntType::new()),
+            op: PredicateOperator::GtEq,
+            literals: vec![Datum::Int(40)],
+        },
+        Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: DataType::Int(crate::spec::IntType::new()),
+            op: PredicateOperator::LtEq,
+            literals: vec![Datum::Int(60)],
+        },
+        Predicate::Leaf {
+            column: "value".to_string(),
+            index: 1,
+            data_type: DataType::Int(crate::spec::IntType::new()),
+            op: PredicateOperator::GtEq,
+            literals: vec![Datum::Int(44)],
+        },
+        Predicate::Leaf {
+            column: "value".to_string(),
+            index: 1,
+            data_type: DataType::Int(crate::spec::IntType::new()),
+            op: PredicateOperator::LtEq,
+            literals: vec![Datum::Int(52)],
+        },
+    ];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+        .await
+        .unwrap();
+    let ranges = result.unwrap();
+    assert_eq!(ranges, vec![RowRange::new(22, 26)]);
+}
+
+#[tokio::test]
+async fn test_multi_field_and_shares_query_concurrency_budget() {
+    let src = format!(
+        "{}/testdata/btree/btree_int_100_no_compress.bin",
+        env!("CARGO_MANIFEST_DIR")
+    );
+    let tmp = tempfile::tempdir().unwrap();
+    let index_dir = tmp.path().join("index");
+    std::fs::create_dir_all(&index_dir).unwrap();
+    let file_names: Vec<_> = (1..=4)
+        .map(|field_id| {
+            let file_name = format!("index_field{field_id}.bin");
+            std::fs::copy(&src, index_dir.join(&file_name)).unwrap();
+            file_name
+        })
+        .collect();
+
+    let table_path = format!("file://{}", tmp.path().display());
+    let file_io = crate::io::FileIOBuilder::new("file").build().unwrap();
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let fields: Vec<_> = (0..4)
+        .map(|index| {
+            let field_id = index + 1;
+            DataField::new(
+                field_id,
+                format!("field{field_id}"),
+                DataType::Int(crate::spec::IntType::new()),
+            )
+        })
+        .collect();
+    let entries: Vec<_> = file_names
+        .iter()
+        .enumerate()
+        .map(|(index, file_name)| {
+            make_global_index_entry(file_name, index as i32 + 1, 0, 99, &meta)
+        })
+        .collect();
+    let predicate = Predicate::and(
+        (0..4)
+            .map(|index| int_eq(&format!("field{}", index + 1), index, 50))
+            .collect(),
+    );
+
+    for (thread_num, expected_peak) in [(1, 1), (2, 2)] {
+        let mut scanner = GlobalIndexScanner::create(
+            &file_io,
+            &table_path,
+            thread_num,
+            i64::MAX,
+            i64::MAX,
+            &entries,
+            &fields,
+        )
+        .unwrap()
+        .unwrap();
+        let probe = Arc::new(QueryIoProbe::default());
+        scanner.query_io_probe = Some(Arc::clone(&probe));
+
+        let result = scanner.evaluate(&predicate).await.unwrap().unwrap();
+
+        assert_eq!(result.row_ranges, vec![RowRange::new(25, 25)]);
+        assert_eq!(result.evaluated_field_ids, HashSet::from([1, 2, 3, 4]));
+        assert_eq!(probe.peak(), expected_peak);
+    }
+}
+
+/// Regression for the Between+remaining bug in `evaluate_leaf`. When a
+/// native `Between` leaf is paired with another conjunct (e.g. `id >= 0`),
+/// and the file's b-tree key range falls **outside** the Between range
+/// but is still matched by the remaining predicate, the whole AND must
+/// produce zero rows. Before the fix, `file_result` was initialized from
+/// the remaining predicate's bitmap and the Between conjunct was silently
+/// dropped — the test would observe the file's full row id set instead of
+/// the empty set.
+#[tokio::test]
+async fn test_between_unmatched_file_drops_remaining_match() {
+    let (file_io, table_path, file_name, _tmp) =
+        setup_testdata_table("btree_int_100_no_compress.bin");
+    // File covers keys [0, 198] (row_ids 0..99). Pick a Between range
+    // entirely below 0 so `may_match_between` is false, and a `>= 0`
+    // conjunct that would otherwise scoop up every row in the file.
+    let meta = BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), false);
+    let entries = vec![make_global_index_entry(&file_name, 1, 0, 99, &meta)];
+    let fields = int_schema_fields();
+
+    let predicates = vec![Predicate::and(vec![
+        Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: DataType::Int(crate::spec::IntType::new()),
+            op: PredicateOperator::Between,
+            literals: vec![Datum::Int(-100), Datum::Int(-50)],
+        },
+        Predicate::Leaf {
+            column: "id".to_string(),
+            index: 0,
+            data_type: DataType::Int(crate::spec::IntType::new()),
+            op: PredicateOperator::GtEq,
+            literals: vec![Datum::Int(0)],
+        },
+    ])];
+
+    let result = evaluate_global_index_fast(&file_io, &table_path, &entries, &predicates, &fields)
+        .await
+        .unwrap();
+    let ranges = result.unwrap();
+    assert!(
+        ranges.is_empty(),
+        "Between(-100..-50) AND id>=0 must produce zero rows on a file \
+             whose key range is [0, 198] — got {ranges:?}"
+    );
+}
diff --git a/crates/paimon/src/table/lumina_index_build_builder.rs b/crates/paimon/src/table/lumina_index_build_builder.rs
index d340c97e..e50a7311 100644
--- a/crates/paimon/src/table/lumina_index_build_builder.rs
+++ b/crates/paimon/src/table/lumina_index_build_builder.rs
@@ -15,28 +15,26 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::lumina::ffi::LuminaBuilder;
+mod extraction;
+mod planning;
+mod validation;
+mod writer;
+
+use extraction::extract_vectors;
+use planning::plan_lumina_shards;
+use validation::{
+    effective_lumina_options, find_index_field, resolve_lumina_options, validate_table_options,
+    validate_vector_field,
+};
+use writer::abort_on_build_error;
+
 use crate::lumina::{
     is_lumina_index_type, LuminaIndexMeta, LuminaVectorIndexOptions, LUMINA_IDENTIFIER,
 };
-use crate::spec::{
-    bucket_dir_name, BinaryRow, CoreOptions, DataField, DataFileMeta, DataType, FileKind,
-    GlobalIndexMeta, IndexFileMeta, ROW_ID_FIELD_NAME,
-};
-use crate::table::source::exclude_row_ranges;
-use crate::table::{
-    CommitMessage, DataSplitBuilder, RowRange, SnapshotManager, Table, TableCommit,
-};
+use crate::spec::CoreOptions;
+use crate::table::{CommitMessage, RowRange, SnapshotManager, Table, TableCommit};
 use crate::{Error, Result};
-use arrow_array::{Array, FixedSizeListArray, Float32Array, Int64Array, ListArray, RecordBatch};
-use bytes::Bytes;
-use futures::TryStreamExt;
 use std::collections::HashMap;
-use std::path::{Path, PathBuf};
-use tokio::io::AsyncReadExt;
-
-const INDEX_DIR: &str = "index";
-const COPY_BUFFER_SIZE: usize = 1024 * 1024;
 
 pub struct LuminaIndexBuildBuilder<'a> {
     table: &'a Table,
@@ -159,1910 +157,42 @@ impl<'a> LuminaIndexBuildBuilder<'a> {
         )
         .await?;
 
+        let commit = TableCommit::new(
+            self.table.clone(),
+            format!(
+                "global-index-{}-create-{}",
+                LUMINA_IDENTIFIER,
+                uuid::Uuid::new_v4()
+            ),
+        );
         let shard_count = shards.len();
         let mut messages = Vec::with_capacity(shard_count);
         for shard in shards {
-            let vectors = extract_vectors(self.table, &shard, index_column, dimension).await?;
-            let index_file = self
-                .build_index_file(
+            let build_result = async {
+                let vectors = extract_vectors(self.table, &shard, index_column, dimension).await?;
+                self.build_index_file(
                     &shard,
                     &vectors,
                     dimension,
                     index_field.id(),
                     index_meta.clone(),
                 )
-                .await?;
+                .await
+            }
+            .await;
+            let index_file = abort_on_build_error(&commit, &messages, build_result).await?;
             let mut message = CommitMessage::new(shard.partition_bytes.clone(), 0, vec![]);
             message.new_index_files = vec![index_file];
             messages.push(message);
         }
 
-        TableCommit::new(
-            self.table.clone(),
-            format!(
-                "global-index-{}-create-{}",
-                LUMINA_IDENTIFIER,
-                uuid::Uuid::new_v4()
-            ),
-        )
-        .commit_if_latest_snapshot(messages, snapshot.id())
-        .await?;
-
-        Ok(shard_count)
-    }
-
-    async fn build_index_file(
-        &self,
-        shard: &LuminaIndexShard,
-        vectors: &[f32],
-        dimension: i32,
-        index_field_id: i32,
-        index_meta: Vec<u8>,
-    ) -> Result<IndexFileMeta> {
-        let row_count = checked_row_count(shard.row_range_start, shard.row_range_end)?;
-        // The native Lumina builder counts rows in an i32; the manifest keeps the
-        // full width.
-        let native_row_count = i32::try_from(row_count).map_err(|_| Error::DataInvalid {
-            message: format!(
-                "Lumina shard row count {row_count} exceeds what the native builder accepts"
-            ),
-            source: None,
-        })?;
-        validate_vector_buffer(vectors, native_row_count, dimension)?;
-        let ids = (0..row_count as u64).collect::<Vec<_>>();
-        let native_options = LuminaIndexMeta::deserialize(&index_meta)?.options().clone();
-
-        let temp_path = temp_lumina_path();
-        let temp_file = TempFileGuard::new(temp_path.clone());
-        let temp_path_str = temp_path.to_string_lossy().to_string();
-        let builder = LuminaBuilder::create(&native_options)?;
-        builder.pretrain(vectors, native_row_count, dimension)?;
-        builder.insert(vectors, &ids, native_row_count, dimension)?;
-        builder.dump(&temp_path_str)?;
-
-        let file_name = format!("lumina-global-index-{}.index", uuid::Uuid::new_v4());
-        self.table
-            .file_io()
-            .mkdirs(&format!(
-                "{}/{INDEX_DIR}/",
-                self.table.location().trim_end_matches('/')
-            ))
-            .await?;
-        let index_path = format!(
-            "{}/{INDEX_DIR}/{}",
-            self.table.location().trim_end_matches('/'),
-            file_name
-        );
-        copy_local_file_to_output(&temp_path, self.table.file_io().new_output(&index_path)?)
+        commit
+            .commit_if_latest_snapshot(messages, snapshot.id())
             .await?;
-        temp_file.cleanup();
-
-        let status = self.table.file_io().get_status(&index_path).await?;
-        Ok(IndexFileMeta {
-            index_type: LUMINA_IDENTIFIER.to_string(),
-            file_name,
-            file_size: checked_i64(
-                status.size,
-                "Index file is too large for Rust IndexFileMeta",
-            )?,
-            row_count,
-            deletion_vectors_ranges: None,
-            global_index_meta: Some(GlobalIndexMeta {
-                row_range_start: shard.row_range_start,
-                row_range_end: shard.row_range_end,
-                index_field_id,
-                extra_field_ids: None,
-                source_meta: None,
-                index_meta: Some(index_meta),
-            }),
-        })
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct LuminaIndexShard {
-    pub partition: BinaryRow,
-    pub partition_bytes: Vec<u8>,
-    pub files: Vec<DataFileMeta>,
-    pub row_range_start: i64,
-    pub row_range_end: i64,
-    snapshot_id: i64,
-    source_bucket: i32,
-    total_buckets: i32,
-    bucket_path: String,
-}
-
-fn validate_table_options(table: &Table, core_options: &CoreOptions) -> Result<()> {
-    if !table.schema().primary_keys().is_empty() {
-        return Err(Error::Unsupported {
-            message: "Lumina index build does not support primary-key tables".to_string(),
-        });
-    }
-    if !core_options.row_tracking_enabled() {
-        return Err(Error::DataInvalid {
-            message: "Lumina index build requires 'row-tracking.enabled' = 'true'".to_string(),
-            source: None,
-        });
-    }
-    if !core_options.data_evolution_enabled() {
-        return Err(Error::DataInvalid {
-            message: "Lumina index build requires 'data-evolution.enabled' = 'true'".to_string(),
-            source: None,
-        });
-    }
-    if !core_options.global_index_enabled() {
-        return Err(Error::DataInvalid {
-            message: "Lumina index build requires 'global-index.enabled' = 'true'".to_string(),
-            source: None,
-        });
-    }
-    if core_options.deletion_vectors_enabled() {
-        return Err(Error::Unsupported {
-            message:
-                "Lumina index build does not support tables with deletion-vectors.enabled=true"
-                    .to_string(),
-        });
-    }
-    Ok(())
-}
-
-fn find_index_field<'a>(table: &'a Table, column: &str) -> Result<&'a DataField> {
-    table
-        .schema()
-        .fields()
-        .iter()
-        .find(|field| field.name() == column)
-        .ok_or_else(|| Error::ColumnNotExist {
-            full_name: table.identifier().full_name(),
-            column: column.to_string(),
-        })
-}
-
-fn validate_vector_field(field: &DataField) -> Result<()> {
-    let is_array_float = matches!(
-        field.data_type(),
-        DataType::Array(array) if matches!(array.element_type(), DataType::Float(_))
-    );
-    let is_vector_float = matches!(
-        field.data_type(),
-        DataType::Vector(vector) if matches!(vector.element_type(), DataType::Float(_))
-    );
-    if !is_array_float && !is_vector_float {
-        return Err(Error::DataInvalid {
-            message: format!(
-                "Lumina index requires ARRAY<FLOAT> or VECTOR<FLOAT> column, got {:?} for column '{}'",
-                field.data_type(),
-                field.name()
-            ),
-            source: None,
-        });
-    }
-    Ok(())
-}
-
-/// For a `VECTOR<FLOAT, N>` column, ensure the effective Lumina options carry
-/// `lumina.index.dimension = N`, so the native options and serialized index
-/// metadata match the column type. Absent → inject N; present-and-equal → keep;
-/// present-and-different → ConfigInvalid. Non-vector columns (e.g. ARRAY<FLOAT>)
-/// are returned unchanged so the existing option/default behavior is preserved.
-fn effective_lumina_options(
-    field: &DataField,
-    mut resolved: HashMap<String, String>,
-) -> Result<HashMap<String, String>> {
-    let DataType::Vector(vector) = field.data_type() else {
-        return Ok(resolved);
-    };
-    let n = vector.length().to_string();
-    match resolved.get(crate::lumina::LUMINA_DIMENSION_OPTION) {
-        None => {
-            resolved.insert(crate::lumina::LUMINA_DIMENSION_OPTION.to_string(), n);
-        }
-        Some(existing) if *existing == n => {}
-        Some(existing) => {
-            return Err(Error::ConfigInvalid {
-                message: format!(
-                    "Vector column '{}' has dimension {} from its type, but '{}' is set to '{}'. \
-                     Remove the option or set it to {}.",
-                    field.name(),
-                    n,
-                    crate::lumina::LUMINA_DIMENSION_OPTION,
-                    existing,
-                    n
-                ),
-            });
-        }
-    }
-    Ok(resolved)
-}
-
-fn resolve_lumina_options(
-    table_options: &HashMap<String, String>,
-    user_options: &HashMap<String, String>,
-) -> Result<HashMap<String, String>> {
-    let mut options = table_options.clone();
-    options.extend(user_options.clone());
-    LuminaVectorIndexOptions::new(&options)?;
-    Ok(options)
-}
-
-#[allow(clippy::too_many_arguments)]
-fn plan_lumina_shards(
-    table_location: &str,
-    partition_keys: &[String],
-    schema_fields: &[DataField],
-    core_options: &CoreOptions,
-    snapshot_id: i64,
-    entries: Vec<crate::spec::ManifestEntry>,
-    rows_per_shard: i64,
-    indexed: &[RowRange],
-) -> Result<Vec<LuminaIndexShard>> {
-    if rows_per_shard <= 0 {
-        return Err(Error::DataInvalid {
-            message: format!(
-                "Option 'global-index.row-count-per-shard' must be greater than 0, got: {rows_per_shard}"
-            ),
-            source: None,
-        });
-    }
-
-    let mut by_partition_bucket: HashMap<(Vec<u8>, i32, i32), Vec<DataFileMeta>> = HashMap::new();
-    for entry in entries {
-        if *entry.kind() != FileKind::Add {
-            continue;
-        }
-        if entry.file().first_row_id.is_none() {
-            return Err(Error::DataInvalid {
-                message: format!(
-                    "Data file '{}' is missing first_row_id; cannot build a complete Lumina index",
-                    entry.file().file_name
-                ),
-                source: None,
-            });
-        }
-        let (partition, bucket, total_buckets, file) = entry.into_parts();
-        by_partition_bucket
-            .entry((partition, bucket, total_buckets))
-            .or_default()
-            .push(file);
-    }
-
-    let mut result = Vec::new();
-    for ((partition_bytes, source_bucket, total_buckets), files) in by_partition_bucket {
-        let partition = if partition_keys.is_empty() {
-            BinaryRow::new(0)
-        } else {
-            BinaryRow::from_serialized_bytes(&partition_bytes)?
-        };
-        let bucket_path = bucket_path(
-            table_location,
-            partition_keys,
-            schema_fields,
-            core_options,
-            &partition,
-            source_bucket,
-        )?;
-        let mut files_by_shard: HashMap<i64, Vec<DataFileMeta>> = HashMap::new();
-        for file in files {
-            let (file_start, file_end) = file.row_id_range().ok_or_else(|| Error::DataInvalid {
-                message: format!(
-                    "Data file '{}' is missing first_row_id; cannot build a complete Lumina index",
-                    file.file_name
-                ),
-                source: None,
-            })?;
-            let start_shard = file_start / rows_per_shard;
-            let end_shard = file_end / rows_per_shard;
-            for shard_id in start_shard..=end_shard {
-                files_by_shard
-                    .entry(shard_id * rows_per_shard)
-                    .or_default()
-                    .push(file.clone());
-            }
-        }
-
-        let mut shard_starts = files_by_shard.keys().copied().collect::<Vec<_>>();
-        shard_starts.sort_unstable();
-        for shard_start in shard_starts {
-            let shard_end = shard_start + rows_per_shard - 1;
-            let mut shard_files = files_by_shard.remove(&shard_start).unwrap_or_default();
-            shard_files.sort_by_key(|file| file.first_row_id);
-            let groups = group_contiguous_files(shard_files)?;
-            for group in groups {
-                let group_start = group
-                    .first()
-                    .and_then(|file| file.first_row_id)
-                    .expect("planned groups are non-empty and row-id assigned");
-                let group_end = group
-                    .iter()
-                    .map(|file| file.row_id_range().unwrap().1)
-                    .max()
-                    .unwrap();
-                // Coverage of this group clamped to the current shard cell. Then
-                // subtract the already-indexed ranges so the build only covers
-                // the gap. Because grid-clamp and gap-subtraction are both range
-                // intersections, applying the gap here is equivalent to btree's
-                // "exclude then split" -- and each surviving segment stays inside
-                // one shard cell, preserving per-shard row-id contiguity.
-                let coverage_start = group_start.max(shard_start);
-                let coverage_end = group_end.min(shard_end);
-                let build_segments =
-                    exclude_row_ranges(&[RowRange::new(coverage_start, coverage_end)], indexed);
-                for seg in build_segments {
-                    result.push(LuminaIndexShard {
-                        partition: partition.clone(),
-                        partition_bytes: partition_bytes.clone(),
-                        files: group.clone(),
-                        row_range_start: seg.from(),
-                        row_range_end: seg.to(),
-                        snapshot_id,
-                        source_bucket,
-                        total_buckets,
-                        bucket_path: bucket_path.clone(),
-                    });
-                }
-            }
-        }
-    }
-    result.sort_by(|a, b| {
-        a.partition
-            .to_serialized_bytes()
-            .cmp(&b.partition.to_serialized_bytes())
-            .then(a.source_bucket.cmp(&b.source_bucket))
-            .then(a.row_range_start.cmp(&b.row_range_start))
-    });
-    Ok(result)
-}
-
-fn group_contiguous_files(mut files: Vec<DataFileMeta>) -> Result<Vec<Vec<DataFileMeta>>> {
-    if files.is_empty() {
-        return Ok(Vec::new());
-    }
-    files.sort_by_key(|file| file.first_row_id);
-    let mut groups = Vec::new();
-    let mut current = Vec::new();
-    let mut current_end = None;
-    for file in files {
-        let (file_start, file_end) = file.row_id_range().ok_or_else(|| Error::DataInvalid {
-            message: format!(
-                "Data file '{}' is missing first_row_id; cannot build a complete Lumina index",
-                file.file_name
-            ),
-            source: None,
-        })?;
-        match current_end {
-            None => {
-                current.push(file);
-                current_end = Some(file_end);
-            }
-            Some(end) if file_start <= end + 1 => {
-                current.push(file);
-                current_end = Some(end.max(file_end));
-            }
-            Some(_) => {
-                groups.push(std::mem::take(&mut current));
-                current.push(file);
-                current_end = Some(file_end);
-            }
-        }
-    }
-    if !current.is_empty() {
-        groups.push(current);
-    }
-    Ok(groups)
-}
-
-fn bucket_path(
-    table_location: &str,
-    partition_keys: &[String],
-    schema_fields: &[DataField],
-    core_options: &CoreOptions,
-    partition: &BinaryRow,
-    bucket: i32,
-) -> Result<String> {
-    let base = table_location.trim_end_matches('/');
-    if partition_keys.is_empty() {
-        return Ok(format!("{base}/{}", bucket_dir_name(bucket)));
-    }
-    let computer = crate::spec::PartitionComputer::new(
-        partition_keys,
-        schema_fields,
-        core_options.partition_default_name(),
-        core_options.legacy_partition_name(),
-    )?;
-    Ok(format!(
-        "{base}/{}{}",
-        computer.generate_partition_path(partition)?,
-        bucket_dir_name(bucket)
-    ))
-}
-
-async fn extract_vectors(
-    table: &Table,
-    shard: &LuminaIndexShard,
-    index_column: &str,
-    dimension: i32,
-) -> Result<Vec<f32>> {
-    let split = DataSplitBuilder::new()
-        .with_snapshot(shard.snapshot_id)
-        .with_partition(shard.partition.clone())
-        .with_bucket(shard.source_bucket)
-        .with_bucket_path(shard.bucket_path.clone())
-        .with_total_buckets(shard.total_buckets)
-        .with_data_files(shard.files.clone())
-        .with_row_ranges(vec![RowRange::new(
-            shard.row_range_start,
-            shard.row_range_end,
-        )])
-        .build()?;
-
-    let mut read_builder = table.new_read_builder();
-    read_builder.with_projection(&[index_column, ROW_ID_FIELD_NAME])?;
-    let read = read_builder.new_read()?;
-    let batches = read.to_arrow(&[split])?.try_collect::<Vec<_>>().await?;
-    extract_vectors_from_batches(
-        &batches,
-        index_column,
-        dimension,
-        shard.row_range_start,
-        checked_row_count(shard.row_range_start, shard.row_range_end)?,
-    )
-}
-
-fn extract_vectors_from_batches(
-    batches: &[RecordBatch],
-    index_column: &str,
-    dimension: i32,
-    row_range_start: i64,
-    expected_row_count: i64,
-) -> Result<Vec<f32>> {
-    let dimension = usize::try_from(dimension).map_err(|e| Error::DataInvalid {
-        message: format!("Invalid Lumina dimension: {dimension}"),
-        source: Some(Box::new(e)),
-    })?;
-    let row_count = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
-    let mut vectors = Vec::with_capacity(row_count * dimension);
-    let mut expected_row_id = row_range_start;
-    for batch in batches {
-        let vector_index =
-            batch
-                .schema()
-                .index_of(index_column)
-                .map_err(|e| Error::DataInvalid {
-                    message: format!("Vector column '{index_column}' not found in read batch: {e}"),
-                    source: None,
-                })?;
-        let row_id_index =
-            batch
-                .schema()
-                .index_of(ROW_ID_FIELD_NAME)
-                .map_err(|e| Error::DataInvalid {
-                    message: format!("_ROW_ID column not found in read batch: {e}"),
-                    source: None,
-                })?;
-        // Resolve the vector column as either List<Float32> (ARRAY<FLOAT>) or
-        // FixedSizeList<Float32> (VECTOR<FLOAT>). Both yield a Float32Array of
-        // values plus a per-row [start, end) slice.
-        let column = batch.column(vector_index);
-        enum VectorLayout<'a> {
-            List(&'a ListArray),
-            Fixed(&'a FixedSizeListArray),
-        }
-        let layout = if let Some(a) = column.as_any().downcast_ref::<ListArray>() {
-            VectorLayout::List(a)
-        } else if let Some(a) = column.as_any().downcast_ref::<FixedSizeListArray>() {
-            VectorLayout::Fixed(a)
-        } else {
-            return Err(Error::DataInvalid {
-                message: "Lumina vector extraction requires Arrow List<Float32> or FixedSizeList<Float32>".to_string(),
-                source: None,
-            });
-        };
-        let values = match layout {
-            VectorLayout::List(a) => a.values(),
-            VectorLayout::Fixed(a) => a.values(),
-        }
-        .as_any()
-        .downcast_ref::<Float32Array>()
-        .ok_or_else(|| Error::DataInvalid {
-            message: "Lumina vector extraction requires Float32 vector elements".to_string(),
-            source: None,
-        })?;
-        let row_ids = batch
-            .column(row_id_index)
-            .as_any()
-            .downcast_ref::<Int64Array>()
-            .ok_or_else(|| Error::DataInvalid {
-                message: "Lumina vector extraction requires non-null Int64 _ROW_ID".to_string(),
-                source: None,
-            })?;
-
-        for row in 0..batch.num_rows() {
-            if row_ids.is_null(row) {
-                return Err(Error::DataInvalid {
-                    message: "Lumina vector extraction found null _ROW_ID".to_string(),
-                    source: None,
-                });
-            }
-            let row_id = row_ids.value(row);
-            if row_id != expected_row_id {
-                return Err(Error::DataInvalid {
-                    message: format!(
-                        "Lumina vector extraction expected _ROW_ID {}, got {}",
-                        expected_row_id, row_id
-                    ),
-                    source: None,
-                });
-            }
-            expected_row_id += 1;
-
-            let is_null = match layout {
-                VectorLayout::List(a) => a.is_null(row),
-                VectorLayout::Fixed(a) => a.is_null(row),
-            };
-            if is_null {
-                return Err(Error::DataInvalid {
-                    message: "Lumina vector extraction found null vector row".to_string(),
-                    source: None,
-                });
-            }
-            let (start, end) = match layout {
-                VectorLayout::List(a) => {
-                    let offsets = a.value_offsets();
-                    (offsets[row] as usize, offsets[row + 1] as usize)
-                }
-                VectorLayout::Fixed(a) => {
-                    let len = a.value_length() as usize;
-                    (row * len, (row + 1) * len)
-                }
-            };
-            if end - start != dimension {
-                return Err(Error::DataInvalid {
-                    message: format!(
-                        "Lumina vector dimension mismatch: expected {}, got {}",
-                        dimension,
-                        end - start
-                    ),
-                    source: None,
-                });
-            }
-            for value_index in start..end {
-                if values.is_null(value_index) {
-                    return Err(Error::DataInvalid {
-                        message: "Lumina vector extraction found null vector element".to_string(),
-                        source: None,
-                    });
-                }
-                vectors.push(values.value(value_index));
-            }
-        }
-    }
-    let actual_row_count = expected_row_id - row_range_start;
-    if actual_row_count != expected_row_count {
-        return Err(Error::DataInvalid {
-            message: format!(
-                "Lumina vector extraction expected {} rows, got {}",
-                expected_row_count, actual_row_count
-            ),
-            source: None,
-        });
-    }
-    Ok(vectors)
-}
-
-fn checked_i64(value: u64, context: &str) -> Result<i64> {
-    i64::try_from(value).map_err(|_| Error::DataInvalid {
-        message: format!("{context}: {value}"),
-        source: None,
-    })
-}
-
-fn checked_row_count(row_range_start: i64, row_range_end: i64) -> Result<i64> {
-    if row_range_end < row_range_start {
-        return Err(Error::DataInvalid {
-            message: format!("Invalid Lumina row range [{row_range_start}, {row_range_end}]"),
-            source: None,
-        });
-    }
-    row_range_end
-        .checked_sub(row_range_start)
-        .and_then(|span| span.checked_add(1))
-        .ok_or_else(|| Error::DataInvalid {
-            message: format!(
-                "Row count overflows for row range [{row_range_start}, {row_range_end}]"
-            ),
-            source: None,
-        })
-}
 
-fn validate_vector_buffer(vectors: &[f32], row_count: i32, dimension: i32) -> Result<()> {
-    if row_count <= 0 {
-        return Err(Error::DataInvalid {
-            message: format!("Lumina shard row count must be positive, got: {row_count}"),
-            source: None,
-        });
-    }
-    if dimension <= 0 {
-        return Err(Error::DataInvalid {
-            message: format!("Lumina vector dimension must be positive, got: {dimension}"),
-            source: None,
-        });
-    }
-    let row_count = row_count as usize;
-    let dimension = dimension as usize;
-    let expected_len = row_count
-        .checked_mul(dimension)
-        .ok_or_else(|| Error::DataInvalid {
-            message: format!(
-                "Lumina vector buffer length overflows: row_count={row_count}, dimension={dimension}"
-            ),
-            source: None,
-        })?;
-    if vectors.len() != expected_len {
-        return Err(Error::DataInvalid {
-            message: format!(
-                "Lumina vector buffer length {} does not match row_count={} and dimension={}",
-                vectors.len(),
-                row_count,
-                dimension
-            ),
-            source: None,
-        });
-    }
-    Ok(())
-}
-
-fn temp_lumina_path() -> PathBuf {
-    std::env::temp_dir().join(format!("lumina-index-{}.index", uuid::Uuid::new_v4()))
-}
-
-struct TempFileGuard {
-    path: Option<PathBuf>,
-}
-
-impl TempFileGuard {
-    fn new(path: PathBuf) -> Self {
-        Self { path: Some(path) }
-    }
-
-    fn cleanup(mut self) {
-        if let Some(path) = self.path.take() {
-            let _ = std::fs::remove_file(path);
-        }
-    }
-}
-
-impl Drop for TempFileGuard {
-    fn drop(&mut self) {
-        if let Some(path) = self.path.take() {
-            let _ = std::fs::remove_file(path);
-        }
-    }
-}
-
-async fn copy_local_file_to_output(
-    source_path: &Path,
-    output: crate::io::OutputFile,
-) -> Result<()> {
-    let mut source =
-        tokio::fs::File::open(source_path)
-            .await
-            .map_err(|e| Error::UnexpectedError {
-                message: format!("Failed to open temporary Lumina index file: {e}"),
-                source: None,
-            })?;
-    let mut writer = output.writer().await?;
-    let mut buffer = vec![0u8; COPY_BUFFER_SIZE];
-
-    loop {
-        let len = source
-            .read(&mut buffer)
-            .await
-            .map_err(|e| Error::UnexpectedError {
-                message: format!("Failed to read temporary Lumina index file: {e}"),
-                source: None,
-            })?;
-        if len == 0 {
-            break;
-        }
-        writer.write(Bytes::copy_from_slice(&buffer[..len])).await?;
+        Ok(shard_count)
     }
-    writer.close().await
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::catalog::Identifier;
-    use crate::io::FileIO;
-    use crate::io::FileIOBuilder;
-    use crate::lumina::LUMINA_DIMENSION_OPTION;
-    use crate::spec::stats::BinaryTableStats;
-    use crate::spec::{
-        ArrayType, DoubleType, FloatType, IndexManifest, IntType, ManifestEntry, Schema,
-        TableSchema, VectorType,
-    };
-    use crate::table::TableWrite;
-    use arrow_array::builder::{FixedSizeListBuilder, Float32Builder, Int64Builder, ListBuilder};
-    use arrow_array::{ArrayRef, Int32Array};
-    use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema};
-    use chrono::{DateTime, Utc};
-    use std::sync::Arc;
-
-    fn data_file(name: &str, first_row_id: Option<i64>, row_count: i64) -> DataFileMeta {
-        DataFileMeta {
-            file_name: name.to_string(),
-            file_size: 128,
-            row_count,
-            min_key: vec![],
-            max_key: vec![],
-            key_stats: BinaryTableStats::new(vec![], vec![], vec![]),
-            value_stats: BinaryTableStats::new(vec![], vec![], vec![]),
-            min_sequence_number: 0,
-            max_sequence_number: 0,
-            schema_id: 0,
-            level: 0,
-            extra_files: vec![],
-            creation_time: Some(
-                "2024-09-06T07:45:55.039+00:00"
-                    .parse::<DateTime<Utc>>()
-                    .unwrap(),
-            ),
-            delete_row_count: None,
-            embedded_index: None,
-            first_row_id,
-            write_cols: None,
-            external_path: None,
-            file_source: None,
-            value_stats_cols: None,
-            column_max_sequence_numbers: None,
-        }
-    }
-
-    fn manifest_entry(file: DataFileMeta) -> ManifestEntry {
-        manifest_entry_with_bucket(file, 0, 1)
-    }
-
-    fn manifest_entry_with_bucket(
-        file: DataFileMeta,
-        bucket: i32,
-        total_buckets: i32,
-    ) -> ManifestEntry {
-        ManifestEntry::new(FileKind::Add, vec![], bucket, total_buckets, file, 2)
-    }
-
-    fn table_options(rows_per_shard: &str) -> HashMap<String, String> {
-        HashMap::from([
-            ("row-tracking.enabled".to_string(), "true".to_string()),
-            ("data-evolution.enabled".to_string(), "true".to_string()),
-            ("global-index.enabled".to_string(), "true".to_string()),
-            (
-                "global-index.row-count-per-shard".to_string(),
-                rows_per_shard.to_string(),
-            ),
-        ])
-    }
-
-    fn test_table(options: HashMap<String, String>) -> Table {
-        test_table_with_io(
-            FileIOBuilder::new("memory").build().unwrap(),
-            "memory:/test_lumina_builder",
-            Schema::builder()
-                .column("id", DataType::Int(IntType::new()))
-                .column(
-                    "embedding",
-                    DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
-                )
-                .options(options)
-                .build()
-                .unwrap(),
-        )
-    }
-
-    fn test_table_with_schema(schema: Schema) -> Table {
-        test_table_with_io(
-            FileIOBuilder::new("memory").build().unwrap(),
-            "memory:/test_lumina_builder",
-            schema,
-        )
-    }
-
-    fn test_table_with_io(file_io: FileIO, table_path: &str, schema: Schema) -> Table {
-        Table::new(
-            file_io,
-            Identifier::new("default", "test_table"),
-            table_path.to_string(),
-            TableSchema::new(0, &schema),
-            None,
-        )
-    }
-
-    fn vector_schema_builder(options: HashMap<String, String>) -> crate::spec::SchemaBuilder {
-        Schema::builder()
-            .column("id", DataType::Int(IntType::new()))
-            .column(
-                "embedding",
-                DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
-            )
-            .options(options)
-    }
-
-    fn plan(entries: Vec<ManifestEntry>, rows_per_shard: i64) -> Result<Vec<LuminaIndexShard>> {
-        plan_with_indexed(entries, rows_per_shard, &[])
-    }
-
-    fn plan_with_indexed(
-        entries: Vec<ManifestEntry>,
-        rows_per_shard: i64,
-        indexed: &[RowRange],
-    ) -> Result<Vec<LuminaIndexShard>> {
-        let table = test_table(table_options(&rows_per_shard.to_string()));
-        let core = CoreOptions::new(table.schema().options());
-        plan_lumina_shards(
-            table.location(),
-            table.schema().partition_keys(),
-            table.schema().fields(),
-            &core,
-            1,
-            entries,
-            rows_per_shard,
-            indexed,
-        )
-    }
-
-    #[test]
-    fn test_planner_splits_single_file_across_shards() {
-        let shards = plan(vec![manifest_entry(data_file("a", Some(0), 25))], 10).unwrap();
-
-        assert_eq!(
-            shards
-                .iter()
-                .map(|s| (s.row_range_start, s.row_range_end))
-                .collect::<Vec<_>>(),
-            vec![(0, 9), (10, 19), (20, 24)]
-        );
-    }
-
-    #[test]
-    fn test_planner_merges_contiguous_files() {
-        let shards = plan(
-            vec![
-                manifest_entry(data_file("a", Some(0), 5)),
-                manifest_entry(data_file("b", Some(5), 5)),
-            ],
-            20,
-        )
-        .unwrap();
-
-        assert_eq!(shards.len(), 1);
-        assert_eq!((shards[0].row_range_start, shards[0].row_range_end), (0, 9));
-        assert_eq!(
-            shards[0]
-                .files
-                .iter()
-                .map(|f| f.file_name.as_str())
-                .collect::<Vec<_>>(),
-            vec!["a", "b"]
-        );
-    }
-
-    #[test]
-    fn test_planner_keeps_source_buckets_separate() {
-        let shards = plan(
-            vec![
-                manifest_entry_with_bucket(data_file("a", Some(0), 5), 0, 2),
-                manifest_entry_with_bucket(data_file("b", Some(5), 5), 1, 2),
-            ],
-            20,
-        )
-        .unwrap();
-
-        assert_eq!(shards.len(), 2);
-        assert_eq!(
-            shards
-                .iter()
-                .map(|s| (
-                    s.source_bucket,
-                    s.total_buckets,
-                    s.row_range_start,
-                    s.row_range_end
-                ))
-                .collect::<Vec<_>>(),
-            vec![(0, 2, 0, 4), (1, 2, 5, 9)]
-        );
-    }
-
-    #[test]
-    fn test_planner_splits_gap_into_separate_groups() {
-        let shards = plan(
-            vec![
-                manifest_entry(data_file("a", Some(0), 5)),
-                manifest_entry(data_file("b", Some(10), 5)),
-            ],
-            20,
-        )
-        .unwrap();
-
-        assert_eq!(
-            shards
-                .iter()
-                .map(|s| (s.row_range_start, s.row_range_end))
-                .collect::<Vec<_>>(),
-            vec![(0, 4), (10, 14)]
-        );
-    }
-
-    #[test]
-    fn test_planner_rejects_missing_first_row_id() {
-        let err = plan(vec![manifest_entry(data_file("a", None, 5))], 10)
-            .expect_err("missing first_row_id should fail");
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("missing first_row_id"))
-        );
-    }
-
-    #[test]
-    fn test_planner_rejects_invalid_rows_per_shard() {
-        let err = plan(vec![manifest_entry(data_file("a", Some(0), 5))], 0)
-            .expect_err("invalid rows per shard should fail");
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("row-count-per-shard"))
-        );
-    }
-
-    #[test]
-    fn test_validate_vector_field_accepts_array_float() {
-        let field = DataField::new(
-            0,
-            "embedding".to_string(),
-            DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
-        );
-        assert!(validate_vector_field(&field).is_ok());
-    }
-
-    #[test]
-    fn test_validate_vector_field_accepts_vector_float() {
-        let field = DataField::new(
-            0,
-            "embedding".to_string(),
-            DataType::Vector(
-                VectorType::try_new(true, 4, DataType::Float(FloatType::new())).unwrap(),
-            ),
-        );
-        assert!(validate_vector_field(&field).is_ok());
-    }
-
-    #[test]
-    fn test_validate_vector_field_rejects_vector_double() {
-        let field = DataField::new(
-            0,
-            "embedding".to_string(),
-            DataType::Vector(
-                VectorType::try_new(true, 4, DataType::Double(DoubleType::new())).unwrap(),
-            ),
-        );
-        let err = validate_vector_field(&field).expect_err("VECTOR<DOUBLE> must be rejected");
-        assert!(matches!(err, Error::DataInvalid { .. }));
-    }
-
-    #[test]
-    fn test_effective_options_vector_absent_inserts_length() {
-        let field = DataField::new(
-            0,
-            "embedding".to_string(),
-            DataType::Vector(
-                VectorType::try_new(true, 256, DataType::Float(FloatType::new())).unwrap(),
-            ),
-        );
-        let opts = effective_lumina_options(&field, HashMap::new()).unwrap();
-        assert_eq!(
-            opts.get(LUMINA_DIMENSION_OPTION).map(String::as_str),
-            Some("256")
-        );
-    }
-
-    #[test]
-    fn test_effective_options_vector_matching_option_ok() {
-        let field = DataField::new(
-            0,
-            "embedding".to_string(),
-            DataType::Vector(
-                VectorType::try_new(true, 256, DataType::Float(FloatType::new())).unwrap(),
-            ),
-        );
-        let resolved = HashMap::from([(LUMINA_DIMENSION_OPTION.to_string(), "256".to_string())]);
-        let opts = effective_lumina_options(&field, resolved).unwrap();
-        assert_eq!(
-            opts.get(LUMINA_DIMENSION_OPTION).map(String::as_str),
-            Some("256")
-        );
-    }
-
-    #[test]
-    fn test_effective_options_vector_mismatch_errors() {
-        let field = DataField::new(
-            0,
-            "embedding".to_string(),
-            DataType::Vector(
-                VectorType::try_new(true, 256, DataType::Float(FloatType::new())).unwrap(),
-            ),
-        );
-        let resolved = HashMap::from([(LUMINA_DIMENSION_OPTION.to_string(), "128".to_string())]);
-        let err =
-            effective_lumina_options(&field, resolved).expect_err("dimension mismatch must error");
-        assert!(matches!(err, Error::ConfigInvalid { .. }));
-    }
-
-    #[test]
-    fn test_effective_options_array_unchanged() {
-        let field = DataField::new(
-            0,
-            "embedding".to_string(),
-            DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
-        );
-        // No dimension option set: array path must NOT inject one.
-        let opts = effective_lumina_options(&field, HashMap::new()).unwrap();
-        assert!(!opts.contains_key(LUMINA_DIMENSION_OPTION));
-    }
-
-    #[test]
-    fn test_vector_without_option_propagates_dimension_to_native_options() {
-        use crate::lumina::{LuminaVectorIndexOptions, KEY_DIMENSION};
-        let field = DataField::new(
-            0,
-            "embedding".to_string(),
-            DataType::Vector(
-                VectorType::try_new(true, 256, DataType::Float(FloatType::new())).unwrap(),
-            ),
-        );
-        // No lumina.index.dimension set by the user.
-        let resolved = effective_lumina_options(&field, HashMap::new()).unwrap();
-        let opts = LuminaVectorIndexOptions::new(&resolved).unwrap();
-
-        assert_eq!(
-            opts.dimension, 256,
-            "local dimension must be N, not default 128"
-        );
-        assert_eq!(
-            opts.to_lumina_options()
-                .get(KEY_DIMENSION)
-                .map(String::as_str),
-            Some("256"),
-            "native index.dimension must be N, not default 128"
-        );
-    }
-
-    #[tokio::test]
-    async fn test_execute_rejects_primary_key_table() {
-        let table = test_table_with_schema(
-            vector_schema_builder(HashMap::new())
-                .primary_key(["id"])
-                .build()
-                .unwrap(),
-        );
-
-        let err = table
-            .new_lumina_index_build_builder()
-            .with_index_column("embedding")
-            .execute()
-            .await
-            .expect_err("primary-key table should fail before native build");
-
-        assert!(
-            matches!(err, Error::Unsupported { message } if message.contains("primary-key tables"))
-        );
-    }
-
-    #[tokio::test]
-    async fn test_execute_rejects_deletion_vectors_table() {
-        let mut options = table_options("10");
-        options.insert("deletion-vectors.enabled".to_string(), "true".to_string());
-        let table = test_table(options);
-
-        let err = table
-            .new_lumina_index_build_builder()
-            .with_index_column("embedding")
-            .execute()
-            .await
-            .expect_err("deletion vectors table should fail before native build");
-
-        assert!(
-            matches!(err, Error::Unsupported { message } if message.contains("deletion-vectors.enabled=true"))
-        );
-    }
-
-    fn vector_batch(rows: Vec<Option<Vec<Option<f32>>>>, row_ids: Vec<Option<i64>>) -> RecordBatch {
-        let mut vector_builder = ListBuilder::new(Float32Builder::new());
-        for row in rows {
-            match row {
-                Some(values) => {
-                    for value in values {
-                        match value {
-                            Some(value) => vector_builder.values().append_value(value),
-                            None => vector_builder.values().append_null(),
-                        }
-                    }
-                    vector_builder.append(true);
-                }
-                None => vector_builder.append(false),
-            }
-        }
-        let mut row_id_builder = Int64Builder::new();
-        for row_id in row_ids {
-            match row_id {
-                Some(value) => row_id_builder.append_value(value),
-                None => row_id_builder.append_null(),
-            }
-        }
-        let schema = Arc::new(ArrowSchema::new(vec![
-            ArrowField::new(
-                "embedding",
-                ArrowDataType::List(Arc::new(ArrowField::new(
-                    "item",
-                    ArrowDataType::Float32,
-                    true,
-                ))),
-                true,
-            ),
-            ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
-        ]));
-        RecordBatch::try_new(
-            schema,
-            vec![
-                Arc::new(vector_builder.finish()) as ArrayRef,
-                Arc::new(row_id_builder.finish()) as ArrayRef,
-            ],
-        )
-        .unwrap()
-    }
-
-    #[test]
-    fn test_extract_vectors_accepts_list_float32_and_row_ids() {
-        let batch = vector_batch(
-            vec![
-                Some(vec![Some(1.0), Some(2.0)]),
-                Some(vec![Some(3.0), Some(4.0)]),
-            ],
-            vec![Some(10), Some(11)],
-        );
-
-        let vectors = extract_vectors_from_batches(&[batch], "embedding", 2, 10, 2).unwrap();
-
-        assert_eq!(vectors, vec![1.0, 2.0, 3.0, 4.0]);
-    }
-
-    #[test]
-    fn test_extract_vectors_rejects_null_vector() {
-        let batch = vector_batch(vec![None], vec![Some(0)]);
-
-        let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
-            .expect_err("null vector should fail");
-
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("null vector"))
-        );
-    }
-
-    #[test]
-    fn test_extract_vectors_rejects_null_element() {
-        let batch = vector_batch(vec![Some(vec![Some(1.0), None])], vec![Some(0)]);
-
-        let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
-            .expect_err("null element should fail");
-
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("null vector element"))
-        );
-    }
-
-    #[test]
-    fn test_extract_vectors_rejects_dimension_mismatch() {
-        let batch = vector_batch(vec![Some(vec![Some(1.0)])], vec![Some(0)]);
-
-        let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
-            .expect_err("dimension mismatch should fail");
-
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("dimension mismatch"))
-        );
-    }
-
-    #[test]
-    fn test_extract_vectors_rejects_row_id_gap() {
-        let batch = vector_batch(
-            vec![
-                Some(vec![Some(1.0), Some(2.0)]),
-                Some(vec![Some(3.0), Some(4.0)]),
-            ],
-            vec![Some(0), Some(2)],
-        );
-
-        let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 2)
-            .expect_err("row id gap should fail");
-
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("expected _ROW_ID"))
-        );
-    }
-
-    #[test]
-    fn test_extract_vectors_rejects_non_list_float32() {
-        let schema = Arc::new(ArrowSchema::new(vec![
-            ArrowField::new("embedding", ArrowDataType::Int32, false),
-            ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
-        ]));
-        let batch = RecordBatch::try_new(
-            schema,
-            vec![
-                Arc::new(Int32Array::from(vec![1])) as ArrayRef,
-                Arc::new(Int64Array::from(vec![Some(0)])) as ArrayRef,
-            ],
-        )
-        .unwrap();
-
-        let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
-            .expect_err("non-list vector should fail");
-
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("List<Float32>"))
-        );
-    }
-
-    fn fixed_size_vector_batch(
-        rows: Vec<Option<Vec<f32>>>,
-        row_ids: Vec<Option<i64>>,
-        len: i32,
-    ) -> RecordBatch {
-        let element_field = Arc::new(ArrowField::new("element", ArrowDataType::Float32, true));
-        let mut builder =
-            FixedSizeListBuilder::new(Float32Builder::new(), len).with_field(element_field);
-        for row in rows {
-            match row {
-                Some(values) => {
-                    for v in values {
-                        builder.values().append_value(v);
-                    }
-                    builder.append(true);
-                }
-                None => {
-                    for _ in 0..len {
-                        builder.values().append_value(0.0);
-                    }
-                    builder.append(false);
-                }
-            }
-        }
-        let mut row_id_builder = Int64Builder::new();
-        for row_id in row_ids {
-            match row_id {
-                Some(value) => row_id_builder.append_value(value),
-                None => row_id_builder.append_null(),
-            }
-        }
-        let schema = Arc::new(ArrowSchema::new(vec![
-            ArrowField::new(
-                "embedding",
-                ArrowDataType::FixedSizeList(
-                    Arc::new(ArrowField::new("element", ArrowDataType::Float32, true)),
-                    len,
-                ),
-                true,
-            ),
-            ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
-        ]));
-        RecordBatch::try_new(
-            schema,
-            vec![
-                Arc::new(builder.finish()) as ArrayRef,
-                Arc::new(row_id_builder.finish()) as ArrayRef,
-            ],
-        )
-        .unwrap()
-    }
-
-    #[test]
-    fn test_extract_vectors_accepts_fixed_size_list_float32() {
-        let batch = fixed_size_vector_batch(
-            vec![Some(vec![1.0, 2.0]), Some(vec![3.0, 4.0])],
-            vec![Some(10), Some(11)],
-            2,
-        );
-        let vectors = extract_vectors_from_batches(&[batch], "embedding", 2, 10, 2).unwrap();
-        assert_eq!(vectors, vec![1.0, 2.0, 3.0, 4.0]);
-    }
-
-    #[test]
-    fn test_extract_vectors_fixed_size_list_rejects_null_vector() {
-        let batch = fixed_size_vector_batch(vec![None], vec![Some(0)], 2);
-        let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
-            .expect_err("null vector should fail");
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("null vector"))
-        );
-    }
-
-    #[test]
-    fn test_extract_vectors_fixed_size_list_dimension_mismatch() {
-        // Column is FixedSizeList of length 3, but caller expects dimension 2.
-        let batch = fixed_size_vector_batch(vec![Some(vec![1.0, 2.0, 3.0])], vec![Some(0)], 3);
-        let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
-            .expect_err("dimension mismatch should fail");
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("dimension mismatch"))
-        );
-    }
-
-    #[test]
-    fn test_extract_vectors_fixed_size_list_rejects_null_element() {
-        // A non-null vector row whose second child element is null. Mirrors the
-        // List path's test_extract_vectors_rejects_null_element so both layouts
-        // reject null elements identically.
-        let element_field = Arc::new(ArrowField::new("element", ArrowDataType::Float32, true));
-        let mut builder =
-            FixedSizeListBuilder::new(Float32Builder::new(), 2).with_field(element_field);
-        builder.values().append_value(1.0);
-        builder.values().append_null();
-        builder.append(true);
-        let row_ids = Arc::new(Int64Array::from(vec![Some(0)])) as ArrayRef;
-        let schema = Arc::new(ArrowSchema::new(vec![
-            ArrowField::new(
-                "embedding",
-                ArrowDataType::FixedSizeList(
-                    Arc::new(ArrowField::new("element", ArrowDataType::Float32, true)),
-                    2,
-                ),
-                true,
-            ),
-            ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
-        ]));
-        let batch = RecordBatch::try_new(
-            schema,
-            vec![Arc::new(builder.finish()) as ArrayRef, row_ids],
-        )
-        .unwrap();
-
-        let err = extract_vectors_from_batches(&[batch], "embedding", 2, 0, 1)
-            .expect_err("null element should fail");
-
-        assert!(
-            matches!(err, Error::DataInvalid { message, .. } if message.contains("null vector element"))
-        );
-    }
-
-    #[test]
-    fn test_checked_metadata_conversion_supports_i64_file_size() {
-        let above_i32_max = i32::MAX as u64 + 1;
-        assert_eq!(
-            checked_i64(above_i32_max, "Index file is too large").unwrap(),
-            i64::from(i32::MAX) + 1
-        );
-
-        let err = checked_i64(i64::MAX as u64 + 1, "Index file is too large")
-            .expect_err("file size above i64::MAX should fail");
-        assert!(matches!(err, Error::DataInvalid { message, .. } if message.contains("too large")));
-    }
-
-    #[test]
-    fn test_temp_file_guard_cleans_up_on_drop() {
-        let path = temp_lumina_path();
-        std::fs::write(&path, b"temporary lumina data").unwrap();
-        {
-            let _guard = TempFileGuard::new(path.clone());
-            assert!(path.exists());
-        }
-        assert!(!path.exists());
-    }
-
-    async fn setup_dirs(file_io: &FileIO, table_path: &str) {
-        file_io
-            .mkdirs(&format!("{table_path}/snapshot/"))
-            .await
-            .unwrap();
-        file_io
-            .mkdirs(&format!("{table_path}/manifest/"))
-            .await
-            .unwrap();
-    }
-
-    fn build_vector_batch(ids: Vec<i32>, vectors: Vec<Vec<f32>>) -> RecordBatch {
-        let element_field = Arc::new(ArrowField::new("element", ArrowDataType::Float32, true));
-        let mut vector_builder =
-            ListBuilder::new(Float32Builder::new()).with_field(element_field.clone());
-        for vector in vectors {
-            for value in vector {
-                vector_builder.values().append_value(value);
-            }
-            vector_builder.append(true);
-        }
-        let schema = Arc::new(ArrowSchema::new(vec![
-            ArrowField::new("id", ArrowDataType::Int32, false),
-            ArrowField::new("embedding", ArrowDataType::List(element_field), true),
-        ]));
-        RecordBatch::try_new(
-            schema,
-            vec![
-                Arc::new(Int32Array::from(ids)) as ArrayRef,
-                Arc::new(vector_builder.finish()) as ArrayRef,
-            ],
-        )
-        .unwrap()
-    }
-
-    // Manual run with a local Lumina native library:
-    // LUMINA_LIB_PATH=/path/to/liblumina_py.so cargo test -p paimon \
-    //     table::lumina_index_build_builder::tests::test_execute_writes_lumina_index_manifest \
-    //     --features fulltext,vortex -- --ignored --exact
-    #[tokio::test]
-    #[ignore = "requires LUMINA_LIB_PATH; see manual run command above"]
-    async fn test_execute_writes_lumina_index_manifest() {
-        let file_io = FileIOBuilder::new("memory").build().unwrap();
-        let table_path = "memory:/test_lumina_builder_e2e";
-        setup_dirs(&file_io, table_path).await;
-
-        let mut options = table_options("10");
-        options.insert("lumina.index.dimension".to_string(), "2".to_string());
-        options.insert("lumina.encoding.type".to_string(), "rawf32".to_string());
-        let table = test_table_with_io(
-            file_io.clone(),
-            table_path,
-            vector_schema_builder(options).build().unwrap(),
-        );
-
-        let mut table_write = TableWrite::new(&table, "test-user".to_string()).unwrap();
-        table_write
-            .write_arrow_batch(&build_vector_batch(
-                vec![1, 2],
-                vec![vec![1.0, 0.0], vec![0.0, 1.0]],
-            ))
-            .await
-            .unwrap();
-        let messages = table_write.prepare_commit().await.unwrap();
-        TableCommit::new(table.clone(), "test-user".to_string())
-            .commit(messages)
-            .await
-            .unwrap();
-
-        let shard_count = table
-            .new_lumina_index_build_builder()
-            .with_index_column("embedding")
-            .execute()
-            .await
-            .unwrap();
-        assert_eq!(shard_count, 1);
-
-        let snapshot_manager = SnapshotManager::new(file_io.clone(), table_path.to_string());
-        let snapshot = snapshot_manager
-            .get_latest_snapshot()
-            .await
-            .unwrap()
-            .unwrap();
-        assert_eq!(snapshot.id(), 2);
-        assert_eq!(snapshot.total_record_count(), Some(2));
-        assert_eq!(snapshot.delta_record_count(), Some(0));
-        assert_eq!(snapshot.next_row_id(), Some(2));
-
-        let index_manifest = snapshot.index_manifest().expect("index manifest");
-        let index_entries =
-            IndexManifest::read(&file_io, &format!("{table_path}/manifest/{index_manifest}"))
-                .await
-                .unwrap();
-        assert_eq!(index_entries.len(), 1);
-
-        let index_file = &index_entries[0].index_file;
-        assert_eq!(index_file.index_type, LUMINA_IDENTIFIER);
-        assert!(index_file.file_name.starts_with("lumina-global-index-"));
-        assert_eq!(index_file.row_count, 2);
-        assert!(index_file.file_size > 0);
-
-        let global_meta = index_file
-            .global_index_meta
-            .as_ref()
-            .expect("global index meta");
-        assert_eq!(global_meta.row_range_start, 0);
-        assert_eq!(global_meta.row_range_end, 1);
-        assert_eq!(global_meta.index_field_id, 1);
-        assert!(global_meta
-            .index_meta
-            .as_ref()
-            .is_some_and(|m| !m.is_empty()));
-
-        let index_path = format!("{table_path}/index/{}", index_file.file_name);
-        let status = file_io.get_status(&index_path).await.unwrap();
-        assert_eq!(index_file.file_size as u64, status.size);
-    }
-
-    fn lumina_e2e_options(rows_per_shard: &str) -> HashMap<String, String> {
-        let mut options = table_options(rows_per_shard);
-        options.insert("lumina.index.dimension".to_string(), "2".to_string());
-        options.insert("lumina.encoding.type".to_string(), "rawf32".to_string());
-        options
-    }
-
-    fn lumina_e2e_table(table_path: &str, rows_per_shard: &str) -> Table {
-        test_table_with_io(
-            FileIOBuilder::new("memory").build().unwrap(),
-            table_path,
-            vector_schema_builder(lumina_e2e_options(rows_per_shard))
-                .build()
-                .unwrap(),
-        )
-    }
-
-    async fn write_vectors(table: &Table, ids: Vec<i32>, vectors: Vec<Vec<f32>>) {
-        let mut table_write = TableWrite::new(table, "test-user".to_string()).unwrap();
-        table_write
-            .write_arrow_batch(&build_vector_batch(ids, vectors))
-            .await
-            .unwrap();
-        let messages = table_write.prepare_commit().await.unwrap();
-        TableCommit::new(table.clone(), "test-user".to_string())
-            .commit(messages)
-            .await
-            .unwrap();
-    }
-
-    /// Commit a synthetic Lumina `IndexFileMeta` covering `[start, end]` for
-    /// `field_id` directly into the index manifest, without invoking the native
-    /// builder. Mirrors the btree mid-hole test so the incremental gap logic can
-    /// be exercised in CI where the native Lumina library is unavailable.
-    async fn commit_synthetic_lumina_index(table: &Table, field_id: i32, start: i64, end: i64) {
-        let synthetic = IndexFileMeta {
-            index_type: LUMINA_IDENTIFIER.to_string(),
-            file_name: format!("lumina-synthetic-{start}-{end}.index"),
-            file_size: 1,
-            row_count: (end - start + 1),
-            deletion_vectors_ranges: None,
-            global_index_meta: Some(GlobalIndexMeta {
-                row_range_start: start,
-                row_range_end: end,
-                index_field_id: field_id,
-                extra_field_ids: None,
-                source_meta: None,
-                index_meta: None,
-            }),
-        };
-        let mut message = CommitMessage::new(BinaryRow::new(0).to_serialized_bytes(), 0, vec![]);
-        message.new_index_files = vec![synthetic];
-        TableCommit::new(table.clone(), "test-user".to_string())
-            .commit(vec![message])
-            .await
-            .unwrap();
-    }
-
-    async fn latest_lumina_index_files(table: &Table) -> Vec<IndexFileMeta> {
-        let snapshot_manager =
-            SnapshotManager::new(table.file_io().clone(), table.location().to_string());
-        let snapshot = snapshot_manager
-            .get_latest_snapshot()
-            .await
-            .unwrap()
-            .unwrap();
-        let Some(index_manifest_name) = snapshot.index_manifest() else {
-            return Vec::new();
-        };
-        IndexManifest::read(
-            table.file_io(),
-            &snapshot_manager.manifest_path(index_manifest_name),
-        )
-        .await
-        .unwrap()
-        .into_iter()
-        .filter(|entry| {
-            entry.kind == FileKind::Add && entry.index_file.index_type == LUMINA_IDENTIFIER
-        })
-        .map(|entry| entry.index_file)
-        .collect()
-    }
-
-    /// Row-id coverage of the committed data files, read back from the data
-    /// manifest (never hard-coded) and merged into contiguous ranges. Mirrors
-    /// how `execute` gathers `manifest_entries`.
-    async fn data_row_id_coverage(table: &Table) -> Vec<RowRange> {
-        let snapshot_manager =
-            SnapshotManager::new(table.file_io().clone(), table.location().to_string());
-        let snapshot = snapshot_manager
-            .get_latest_snapshot()
-            .await
-            .unwrap()
-            .unwrap();
-        let entries = table
-            .new_read_builder()
-            .new_scan()
-            .with_scan_all_files()
-            .plan_manifest_entries(&snapshot)
-            .await
-            .unwrap();
-        let ranges = entries
-            .iter()
-            .filter(|entry| *entry.kind() == FileKind::Add)
-            .filter_map(|entry| {
-                entry
-                    .file()
-                    .row_id_range()
-                    .map(|(start, end)| RowRange::new(start, end))
-            })
-            .collect::<Vec<_>>();
-        crate::table::merge_row_ranges(ranges)
-    }
-
-    /// Second build with the whole coverage already indexed must be a clean
-    /// no-op (returns 0), not an overlap error. Reaches `Ok(0)` before the
-    /// native build, so it runs in CI without the Lumina library. This is the
-    /// core bug fix: today the second call errors with the overlap message.
-    #[tokio::test]
-    async fn lumina_second_build_without_new_data_is_noop() {
-        let table_path = "memory:/test_lumina_second_build_noop";
-        let table = lumina_e2e_table(table_path, "10");
-        setup_dirs(table.file_io(), table_path).await;
-
-        write_vectors(
-            &table,
-            vec![1, 2, 3],
-            vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]],
-        )
-        .await;
-
-        // Fully index the coverage via a synthetic manifest entry.
-        let coverage = data_row_id_coverage(&table).await;
-        assert_eq!(coverage.len(), 1, "data must be one contiguous range");
-        let field_id = find_index_field(&table, "embedding").unwrap().id();
-        commit_synthetic_lumina_index(&table, field_id, coverage[0].from(), coverage[0].to()).await;
-
-        let names_before = latest_lumina_index_files(&table)
-            .await
-            .iter()
-            .map(|f| f.file_name.clone())
-            .collect::<std::collections::BTreeSet<_>>();
-        assert!(!names_before.is_empty());
-
-        let built = table
-            .new_lumina_index_build_builder()
-            .with_index_column("embedding")
-            .execute()
-            .await
-            .unwrap();
-        assert_eq!(built, 0, "fully-indexed table must build nothing on re-run");
-
-        let names_after = latest_lumina_index_files(&table)
... 12734 lines suppressed ...