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

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


The following commit(s) were added to refs/heads/master by this push:
     new 663e598035 [python] Verify candidate-only scalar filters before vector 
top-k (#9999)
663e598035 is described below

commit 663e59803555aaf57cab1b022959e4e73a10a843
Author: chaoyang <[email protected]>
AuthorDate: Sun Sep 20 16:10:51 2026 +0800

    [python] Verify candidate-only scalar filters before vector top-k (#9999)
---
 docs/docs/pypaimon/multimodal-search.md            |   9 +
 .../pypaimon/common/options/core_options.py        |  12 ++
 .../globalindex/btree/btree_index_reader.py        |   8 +-
 .../data_evolution_global_index_scanner.py         |   3 +-
 .../pypaimon/globalindex/global_index_evaluator.py |  11 +-
 .../pypaimon/globalindex/global_index_result.py    |  22 +-
 .../pypaimon/table/source/vector_search_read.py    |  39 +++-
 .../tests/ray_vector_filter_exactness_test.py      |  60 ++++++
 .../pypaimon/tests/vector_filter_exactness_test.py | 223 +++++++++++++++++++++
 9 files changed, 372 insertions(+), 15 deletions(-)

diff --git a/docs/docs/pypaimon/multimodal-search.md 
b/docs/docs/pypaimon/multimodal-search.md
index 2431e48772..2fc052bd7f 100644
--- a/docs/docs/pypaimon/multimodal-search.md
+++ b/docs/docs/pypaimon/multimodal-search.md
@@ -63,6 +63,15 @@ filter the rows read from the search result. Both 
`pre_filter` and `where()`
 accept SQL-like predicate strings. For full-text search, `pre_filter` must only
 reference partition columns.
 
+For data-evolution vector search, a scalar index may return candidates rather 
than exact
+matches, for example for BTree string-prefix or substring predicates, or when
+part of a conjunction is unsupported. Such index candidates are excluded with
+a warning by default, so the result can contain fewer than the requested rows.
+Set the table option `global-index.filter.refine-from-data=true` to verify 
those
+candidates before vector top-k selection. This reads the filter columns at the
+search snapshot and may scan every candidate row; exact index matches need no
+extra read. This applies to single and batch vector queries, locally and on 
Ray.
+
 Each execution of `search`, `search_vectors`, or `search_hybrid` reads one
 snapshot across candidate search, filtering, reranking, and result lookup.
 Concurrent commits become visible on the next execution, including when reusing
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 801b73e531..5d4cfa34dd 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -956,6 +956,15 @@ class CoreOptions:
         )
     )
 
+    GLOBAL_INDEX_FILTER_REFINE_FROM_DATA: ConfigOption[bool] = (
+        ConfigOptions.key("global-index.filter.refine-from-data")
+        .boolean_type()
+        .default_value(False)
+        .with_description(
+            "Whether vector search may read filter columns to verify 
candidate-only scalar index matches. "
+            "When false, inexact index candidates are excluded from the 
search.")
+    )
+
     GLOBAL_INDEX_THREAD_NUM: ConfigOption[int] = (
         ConfigOptions.key("global-index.thread-num")
         .int_type()
@@ -1742,6 +1751,9 @@ class CoreOptions:
     def global_index_thread_num(self) -> Optional[int]:
         return self.options.get(CoreOptions.GLOBAL_INDEX_THREAD_NUM)
 
+    def global_index_filter_refine_from_data(self) -> bool:
+        return 
self.options.get(CoreOptions.GLOBAL_INDEX_FILTER_REFINE_FROM_DATA)
+
     def global_index_row_count_per_shard(self) -> int:
         return self.options.get(CoreOptions.GLOBAL_INDEX_ROW_COUNT_PER_SHARD)
 
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py 
b/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
index c1d0b6007e..f553960886 100644
--- a/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
@@ -239,16 +239,16 @@ class BTreeIndexReader:
         return GlobalIndexResult.create(result)
 
     def visit_starts_with(self, literal: object) -> 
Optional[GlobalIndexResult]:
-        return GlobalIndexResult.create(self._all_non_null_rows())
+        return GlobalIndexResult.create(self._all_non_null_rows(), 
is_exact=False)
 
     def visit_ends_with(self, literal: object) -> Optional[GlobalIndexResult]:
-        return GlobalIndexResult.create(self._all_non_null_rows())
+        return GlobalIndexResult.create(self._all_non_null_rows(), 
is_exact=False)
 
     def visit_contains(self, literal: object) -> Optional[GlobalIndexResult]:
-        return GlobalIndexResult.create(self._all_non_null_rows())
+        return GlobalIndexResult.create(self._all_non_null_rows(), 
is_exact=False)
 
     def visit_like(self, literal: object) -> Optional[GlobalIndexResult]:
-        return GlobalIndexResult.create(self._all_non_null_rows())
+        return GlobalIndexResult.create(self._all_non_null_rows(), 
is_exact=False)
 
     def visit_between(self, min_v: object, max_v: object) -> 
Optional[GlobalIndexResult]:
         return GlobalIndexResult.create(
diff --git 
a/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py 
b/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
index e7acdee35c..1dbbfb0154 100644
--- a/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
+++ b/paimon-python/pypaimon/globalindex/data_evolution_global_index_scanner.py
@@ -282,7 +282,8 @@ class _IndexMetaFileGroup:
 class _PaddingGlobalIndexReader(GlobalIndexReader):
     def __init__(self, wrapped, padding):
         self._wrapped = wrapped
-        self._padding = padding
+        # Padding rows have not been tested by this index.
+        self._padding = GlobalIndexResult.create(padding.results(), 
is_exact=False)
 
     def _pad(self, future):
         return _map_future(
diff --git a/paimon-python/pypaimon/globalindex/global_index_evaluator.py 
b/paimon-python/pypaimon/globalindex/global_index_evaluator.py
index b0c8a0ed2c..fe786e604b 100644
--- a/paimon-python/pypaimon/globalindex/global_index_evaluator.py
+++ b/paimon-python/pypaimon/globalindex/global_index_evaluator.py
@@ -130,7 +130,11 @@ class GlobalIndexEvaluator:
             if child_result is None:
                 continue
             if compound_result is not None:
-                compound_result = compound_result.and_(child_result)
+                # Readers answer the same predicate: an exact result 
intersected
+                # with a candidate superset remains exact.
+                is_exact = compound_result.is_exact() or 
child_result.is_exact()
+                compound_result = GlobalIndexResult.create(
+                    compound_result.and_(child_result).results(), 
is_exact=is_exact)
             else:
                 compound_result = child_result
             if compound_result.is_empty():
@@ -195,6 +199,11 @@ class GlobalIndexEvaluator:
                     break
             if compound_result is None:
                 return None
+            if any(child is None for child in results):
+                # A dropped AND child can share a field with a supported child.
+                # Contributing field ids alone therefore cannot prove 
exactness.
+                compound_result = GlobalIndexResult.create(
+                    compound_result.results(), is_exact=False)
             return GlobalIndexEvaluation(compound_result,
                                          frozenset(contributing_field_ids))
 
diff --git a/paimon-python/pypaimon/globalindex/global_index_result.py 
b/paimon-python/pypaimon/globalindex/global_index_result.py
index 709e2d4393..4a63103eb5 100644
--- a/paimon-python/pypaimon/globalindex/global_index_result.py
+++ b/paimon-python/pypaimon/globalindex/global_index_result.py
@@ -30,6 +30,10 @@ class GlobalIndexResult(ABC):
         """Returns the bitmap representing row ids."""
         pass
 
+    def is_exact(self) -> bool:
+        """Whether these row ids are matches rather than a candidate 
superset."""
+        return True
+
     def offset(self, start_offset: int) -> 'GlobalIndexResult':
         """Returns a new result with row IDs offset by the given amount."""
         if start_offset == 0:
@@ -38,18 +42,20 @@ class GlobalIndexResult(ABC):
         offset_bitmap = RoaringBitmap64()
         for row_id in bitmap:
             offset_bitmap.add(row_id + start_offset)
-        return SimpleGlobalIndexResult(offset_bitmap)
+        return SimpleGlobalIndexResult(offset_bitmap, self.is_exact())
 
     def and_(self, other: 'GlobalIndexResult') -> 'GlobalIndexResult':
         """Returns the intersection of this result and the other result."""
         return SimpleGlobalIndexResult(
-            RoaringBitmap64.and_(self.results(), other.results())
+            RoaringBitmap64.and_(self.results(), other.results()),
+            self.is_exact() and other.is_exact(),
         )
 
     def or_(self, other: 'GlobalIndexResult') -> 'GlobalIndexResult':
         """Returns the union of this result and the other result."""
         return SimpleGlobalIndexResult(
-            RoaringBitmap64.or_(self.results(), other.results())
+            RoaringBitmap64.or_(self.results(), other.results()),
+            self.is_exact() and other.is_exact(),
         )
 
     def is_empty(self) -> bool:
@@ -62,9 +68,9 @@ class GlobalIndexResult(ABC):
         return SimpleGlobalIndexResult(RoaringBitmap64())
 
     @staticmethod
-    def create(bitmap: RoaringBitmap64) -> 'GlobalIndexResult':
+    def create(bitmap: RoaringBitmap64, is_exact: bool = True) -> 
'GlobalIndexResult':
         """Returns a new GlobalIndexResult wrapping the given bitmap."""
-        return SimpleGlobalIndexResult(bitmap)
+        return SimpleGlobalIndexResult(bitmap, is_exact)
 
     @staticmethod
     def from_range(range_: Range) -> 'GlobalIndexResult':
@@ -84,8 +90,12 @@ class GlobalIndexResult(ABC):
 
 class SimpleGlobalIndexResult(GlobalIndexResult):
 
-    def __init__(self, result: RoaringBitmap64):
+    def __init__(self, result: RoaringBitmap64, is_exact: bool = True):
         self._result = result
+        self._is_exact = is_exact
+
+    def is_exact(self) -> bool:
+        return self._is_exact or self.is_empty()
 
     def results(self) -> RoaringBitmap64:
         return self._result
diff --git a/paimon-python/pypaimon/table/source/vector_search_read.py 
b/paimon-python/pypaimon/table/source/vector_search_read.py
index f48f1b10f4..59909966d0 100644
--- a/paimon-python/pypaimon/table/source/vector_search_read.py
+++ b/paimon-python/pypaimon/table/source/vector_search_read.py
@@ -17,6 +17,7 @@
 
 """Vector search read to read index files."""
 
+import logging
 from abc import ABC, abstractmethod
 from collections import deque
 from concurrent.futures import ThreadPoolExecutor
@@ -181,11 +182,43 @@ class AbstractVectorSearchReadImpl:
             return RoaringBitmap64()
         try:
             result = scanner.scan(self._filter)
-            if result is None:
-                return RoaringBitmap64()
-            return result.results()
         finally:
             scanner.close()
+        if result is not None and result.is_exact():
+            return result.results()
+        if not self._table.options.global_index_filter_refine_from_data():
+            logging.getLogger(__name__).warning(
+                "Scalar index candidates are excluded because the row filter 
%s cannot be "
+                "evaluated exactly. Set 
global-index.filter.refine-from-data=true to verify "
+                "candidates against the data; otherwise vector search may 
return fewer rows.",
+                self._filter)
+            return RoaringBitmap64()
+
+        candidates = RoaringBitmap64()
+        for split in splits:
+            candidates.add_range(split.row_range_start, split.row_range_end)
+        if result is not None:
+            candidates = RoaringBitmap64.and_(candidates, result.results())
+        return self._matching_candidate_rows(candidates, snapshot)
+
+    def _matching_candidate_rows(self, candidates, snapshot):
+        from pypaimon.read.table_read import _ClosableArrowBatchReader
+
+        matched = RoaringBitmap64()
+        if candidates.is_empty():
+            return matched
+        table = global_index_live_row_filter.table_at_snapshot(self._table, 
snapshot)
+        builder = (table.new_read_builder().with_filter(self._filter)
+                   .with_projection([SpecialFields.ROW_ID.name]))
+        if self._partition_filter is not None:
+            builder = builder.with_partition_filter(self._partition_filter)
+        splits = 
builder.new_scan().with_row_ranges(candidates.to_range_list()).plan().splits()
+        reader, batches = builder.new_read()._new_arrow_batch_reader(splits)
+        with _ClosableArrowBatchReader(reader, batches) as batch_reader:
+            for batch in batch_reader:
+                for row_id in 
batch.column(SpecialFields.ROW_ID.name).to_pylist():
+                    matched.add(row_id)
+        return matched
 
     def _pre_filter(self, splits, snapshot=None):
         # Backwards-compatible helper used by older tests/callers.
diff --git a/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py 
b/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
new file mode 100644
index 0000000000..eae220840a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_vector_filter_exactness_test.py
@@ -0,0 +1,60 @@
+# 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.
+
+from unittest.mock import patch
+
+import pytest
+
+pytest.importorskip("ray")
+
+from pypaimon.read.table_read import TableRead
+from pypaimon.table.source.vector_search_read import 
AbstractVectorSearchReadImpl
+from pypaimon.tests import ray_vector_search_test as ray_fixtures
+from pypaimon.tests import vector_filter_exactness_test as fixtures
+from pypaimon.tests.vector_filter_exactness_test import query, scalar_index
+
+ray_cluster = ray_fixtures.ray_cluster
+table = fixtures.table
+
+
[email protected]("batch", [False, True])
[email protected]("mode", ["full", "fast"])
[email protected]("refine", [False, True])
+def test_ray_applies_exact_row_filter_before_worker_top_k(table, ray_cluster, 
batch, mode, refine):
+    scalar_index(table)
+    table.raw_table = table.raw_table.copy({
+        "vector-index.search-mode": mode, 
"global-index.filter.refine-from-data": str(refine).lower()})
+    original = AbstractVectorSearchReadImpl._matching_candidate_rows
+    arrow_read = TableRead._new_arrow_batch_reader
+    calls = []
+
+    def no_vectors(read, *args, **kwargs):
+        assert "embedding" not in [field.name for field in read.read_type]
+        return arrow_read(read, *args, **kwargs)
+
+    def verify(reader, candidates, snapshot):
+        calls.append(list(candidates))
+        assert snapshot is not None
+        with patch.object(TableRead, "_new_arrow_batch_reader", no_vectors):
+            return original(reader, candidates, snapshot)
+
+    with patch.object(AbstractVectorSearchReadImpl, 
"_matching_candidate_rows", verify):
+        result = query(table, "name LIKE '%zeta%'", 
batch).to_arrow(execution="ray", concurrency=2)
+    actual = [value.to_pylist() for value in result] if batch else 
result.to_pylist()
+    expected = [{"id": 1}] if refine else []
+    assert actual == ([expected, expected] if batch else expected)
+    assert calls == ([[0, 1, 2]] if refine else [])
diff --git a/paimon-python/pypaimon/tests/vector_filter_exactness_test.py 
b/paimon-python/pypaimon/tests/vector_filter_exactness_test.py
new file mode 100644
index 0000000000..b4fa8be79c
--- /dev/null
+++ b/paimon-python/pypaimon/tests/vector_filter_exactness_test.py
@@ -0,0 +1,223 @@
+# 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.
+
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+import pypaimon.multimodal as pm
+from pypaimon.common.predicate import Predicate
+from pypaimon.globalindex.data_evolution_global_index_scanner import 
_PaddingGlobalIndexReader
+from pypaimon.globalindex.global_index_evaluator import GlobalIndexEvaluator
+from pypaimon.globalindex.global_index_reader import FieldRef, 
_completed_future
+from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.globalindex.offset_global_index_reader import 
OffsetGlobalIndexReader
+from pypaimon.globalindex.union_global_index_reader import 
UnionGlobalIndexReader
+from pypaimon.table.source.vector_search_read import 
AbstractVectorSearchReadImpl, DataEvolutionVectorRead
+from pypaimon.tests.vector_search_filter_test import _StubTable, _field
+from pypaimon.tests.global_index_evaluator_test import StubGlobalIndexReader, 
_make_fields
+from pypaimon.utils.range import Range
+
+
[email protected]
+def table(tmp_path):
+    pytest.importorskip("paimon_vindex")
+    schema = pa.schema([("id", pa.int64()), ("name", pa.string()), 
("embedding", pa.list_(pa.float32(), 2))])
+    table = pm.connect(options={"warehouse": str(tmp_path)}).create_table(
+        "vectors", schema=schema, options={"file.format": "parquet", 
"vector.file.format": "parquet",
+                                           "read.batch-size": "1"})
+    table.add(pa.table({"id": [0, 1, 2], "name": ["alpha", "beta zeta", 
"gamma"],
+                        "embedding": [[0., 1.], [1., 1.], [2., 1.]]}, 
schema=schema))
+    table.raw_table.copy({"deletion-vectors.enabled": 
"false"}).create_global_index(
+        "embedding", "ivf-flat", options={"ivf-flat.nlist": "1", 
"ivf-flat.distance.metric": "l2"})
+    return table
+
+
+def scalar_index(table, kind="btree"):
+    table.raw_table.copy({"deletion-vectors.enabled": 
"false"}).create_global_index("name", kind)
+
+
+def query(table, predicate, batch=False):
+    search = table.search_vectors([[0., 1.], [0., 1.]], pre_filter=predicate) 
if batch else table.search(
+        [0., 1.], pre_filter=predicate)
+    return search.select(["id"]).limit(1)
+
+
[email protected]("pattern", ["%zeta%", "beta%"])
[email protected]("batch", [False, True])
[email protected]("mode", ["full", "fast"])
[email protected]("refine", [False, True])
+def test_btree_candidates_are_verified_before_top_k(table, caplog, pattern, 
batch, mode, refine):
+    scalar_index(table)
+    table.raw_table = table.raw_table.copy({
+        "vector-index.search-mode": mode, 
"global-index.filter.refine-from-data": str(refine).lower()})
+    result = query(table, "name LIKE '%s'" % pattern, batch).to_list()
+    expected = [{"id": 1}] if refine else []
+    assert result == ([expected, expected] if batch else expected)
+    assert ("global-index.filter.refine-from-data=true" in caplog.text) == 
(not refine)
+
+
[email protected]("kind, predicate", [
+    ("btree", "name = 'beta zeta'"), ("btree", "name >= 'beta' AND name < 
'gamma'"),
+    ("btree", "name LIKE 'beta zeta'"), ("bitmap", "name LIKE '%zeta%'"),
+])
+def test_exact_indexes_do_not_read_filter_columns(table, kind, predicate):
+    scalar_index(table, kind)
+    table.raw_table = 
table.raw_table.copy({"global-index.filter.refine-from-data": "true"})
+    with patch.object(AbstractVectorSearchReadImpl, "_matching_candidate_rows",
+                      side_effect=AssertionError("exact index recheck")):
+        assert query(table, predicate).to_list() == [{"id": 1}]
+
+
[email protected]("batch", [False, True])
[email protected]("mode", ["full", "fast"])
[email protected]("refine", [False, True])
+def test_mixed_btree_and_bitmap_preserve_exact_matches(table, batch, mode, 
refine):
+    scalar_index(table, "btree")
+    scalar_index(table, "bitmap")
+    table.raw_table = table.raw_table.copy({
+        "vector-index.search-mode": mode, 
"global-index.filter.refine-from-data": str(refine).lower()})
+    with patch.object(AbstractVectorSearchReadImpl, "_matching_candidate_rows",
+                      side_effect=AssertionError("exact index recheck")):
+        result = query(table, "name LIKE '%zeta%'", batch).to_list()
+    expected = [{"id": 1}]
+    assert result == ([expected, expected] if batch else expected)
+
+
[email protected]("method", ["leaf", "and"])
[email protected]("first_exact, second_exact", [(False, False), (False, 
True), (True, False), (True, True)])
+def test_reader_intersection_and_predicate_conjunction_exactness(method, 
first_exact, second_exact):
+    results = [GlobalIndexResult.create(
+        GlobalIndexResult.from_range(Range(1, 2) if exact else Range(0, 
3)).results(), is_exact=exact)
+        for exact in (first_exact, second_exact)]
+    readers = [StubGlobalIndexReader(result) for result in results]
+    leaves = [Predicate(method="equal", index=i, field=field, literals=[1]) 
for i, field in enumerate(("a", "b"))]
+    predicate = leaves[0] if method == "leaf" else Predicate(
+        method="and", index=None, field=None, literals=leaves)
+    with GlobalIndexEvaluator(_make_fields(), lambda field: readers + 
[StubGlobalIndexReader(None)]
+                              if method == "leaf" else [readers[field.id]]) as 
evaluator:
+        result = evaluator.evaluate(predicate)
+    assert list(result.results()) == ([1, 2] if first_exact or second_exact 
else [0, 1, 2, 3])
+    assert result.is_exact() == ((first_exact or second_exact) if method == 
"leaf" else (
+        first_exact and second_exact))
+
+
[email protected]("predicate", ["name LIKE '%zeta%'", "name >= 'a' AND 
name LIKE '%zeta%'"])
+def test_unsupported_leaf_and_same_field_conjunction_can_be_refined(table, 
predicate):
+    scalar_index(table)
+    table.raw_table = 
table.raw_table.copy({"global-index.filter.refine-from-data": "true",
+                                            
"btree-index.fallback-scan-max-size": "0 b"})
+    assert query(table, predicate).to_list() == [{"id": 1}]
+
+
+def test_commit_before_candidate_verification_keeps_snapshot(table):
+    scalar_index(table)
+    table.raw_table = 
table.raw_table.copy({"global-index.filter.refine-from-data": "true",
+                                            
"global-index.column-update-action": "DROP_PARTITION_INDEX"})
+    search = query(table, "name LIKE '%zeta%'")
+    original = AbstractVectorSearchReadImpl._matching_candidate_rows
+
+    def update_before_read(reader, candidates, snapshot):
+        table.update("id = 1", {"name": "changed"})
+        return original(reader, candidates, snapshot)
+
+    with patch.object(AbstractVectorSearchReadImpl, 
"_matching_candidate_rows", update_before_read):
+        assert search.to_list() == [{"id": 1}]
+    assert search.to_list() == []
+
+
[email protected]("method", ["and", "or"])
+def test_exactness_follows_predicate_tree_and_reader_wrappers(method):
+    candidate = GlobalIndexResult.create(GlobalIndexResult.from_range(Range(0, 
3)).results(), is_exact=False)
+    exact = GlobalIndexResult.from_range(Range(1, 2))
+    assert not candidate.offset(10).is_exact()
+    assert not candidate.and_(exact).is_exact()
+    assert not candidate.or_(exact).is_exact()
+    assert candidate.and_(GlobalIndexResult.create_empty()).is_exact()
+    reader = 
UnionGlobalIndexReader([OffsetGlobalIndexReader(StubGlobalIndexReader(candidate),
 10, 13)])
+    assert not reader.visit_equal(FieldRef(0, "a", "INT"), 
1).result().is_exact()
+    padded = _PaddingGlobalIndexReader(StubGlobalIndexReader(exact), 
GlobalIndexResult.from_range(Range(5, 6)))
+    assert not padded.visit_equal(FieldRef(0, "a", "INT"), 
1).result().is_exact()
+
+    class PartialReader(StubGlobalIndexReader):
+        def visit_greater_than(self, field_ref, literal):
+            return _completed_future(None)
+
+    evaluator = GlobalIndexEvaluator(_make_fields(), lambda field: 
[PartialReader(exact)])
+    predicate = Predicate(method=method, index=None, field=None, literals=[
+        Predicate(method="equal", index=0, field="a", literals=[1]),
+        Predicate(method="greaterThan", index=0, field="a", literals=[0]),
+    ])
+    try:
+        result = evaluator.evaluate(predicate)
+        if method == "or":
+            assert result is None
+        else:
+            assert list(result.results()) == [1, 2]
+            assert not result.is_exact()
+    finally:
+        evaluator.close()
+
+
[email protected]("selector", ["snapshot", "tag"])
+def test_candidate_verification_preserves_historical_deletions(table, 
selector):
+    scalar_index(table)
+    table.raw_table = 
table.raw_table.copy({"global-index.filter.refine-from-data": "true"})
+    saved = table.raw_table.snapshot_manager().get_latest_snapshot()
+    table.raw_table.create_tag("saved", snapshot_id=saved.id)
+    table.delete("id = 1")
+    options = {"snapshot_id": saved.id} if selector == "snapshot" else 
{"tag_name": "saved"}
+    search = table.search([0., 1.], pre_filter="name LIKE '%zeta%'", 
**options).select(["id"]).limit(1)
+    assert search.to_list() == [{"id": 1}]
+    assert query(table, "name LIKE '%zeta%'").to_list() == []
+
+
[email protected]("fail_read", [False, True])
+def 
test_candidate_verification_projects_only_row_ids_and_closes_stream(fail_read):
+    field = _field(1, "embedding", "FLOAT")
+    table = _StubTable([field], [])
+    read = DataEvolutionVectorRead(table, 1, field, [0.], filter_="filter")
+    builder = Mock()
+    builder.with_filter.return_value = builder
+    builder.with_projection.return_value = builder
+    table.new_read_builder = Mock(return_value=builder)
+    schema = pa.schema([("_ROW_ID", pa.int64())])
+    closed = []
+
+    def batches():
+        try:
+            yield pa.record_batch([[1]], schema=schema)
+            if fail_read:
+                raise ValueError("filter read failed")
+        finally:
+            closed.append(True)
+
+    source = batches()
+    resource = Mock()
+    resource.read_next_batch.side_effect = lambda: next(source)
+    builder.new_read.return_value._new_arrow_batch_reader.return_value = 
resource, source
+    candidates = GlobalIndexResult.from_range(Range(0, 2)).results()
+    if fail_read:
+        with pytest.raises(ValueError, match="filter read failed"):
+            read._matching_candidate_rows(candidates, None)
+    else:
+        assert list(read._matching_candidate_rows(candidates, None)) == [1]
+    builder.with_filter.assert_called_once_with("filter")
+    builder.with_projection.assert_called_once_with(["_ROW_ID"])
+    resource.close.assert_called_once_with()
+    assert closed == [True]

Reply via email to