Skip to content

Commit 093028e

Browse files
committed
Expand PyLucene CAGRA search coverage
1 parent df836cc commit 093028e

4 files changed

Lines changed: 147 additions & 43 deletions

File tree

README.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ directly against existing artifacts instead:
8484
```sh
8585
CUVS_LUCENE_JAR=/path/to/cuvs-lucene.jar \
8686
CUVS_LUCENE_CUVS_JAVA_JAR=/path/to/cuvs-java.jar \
87-
python3 -m pytest -q -s examples/Python/test_pylucene_smoke.py
87+
python3 -m pytest -q -s src/test/python/test_pylucene_smoke.py
8888
```
8989

9090
To run an expanded GPU end-to-end pytest suite through CPU HNSW,
@@ -94,9 +94,10 @@ CAGRA-to-HNSW, and CAGRA search paths:
9494
./test_pylucene.sh --gpu-e2e
9595
```
9696

97-
The expanded suite runs the `gpu-basic`, `gpu-segments`, `cpu-hnsw`, and
98-
`cagra-hnsw` case groups. The basic cases cover `hnsw`, `cagra`, `hnsw-single`,
99-
and `cagra-single`. The segment cases cover 1-segment indexes, 10-segment
97+
The expanded suite runs the `gpu-basic`, `gpu-segments`, `cagra-search`,
98+
`cpu-hnsw`, and `cagra-hnsw` case groups. The basic cases cover `hnsw`,
99+
`cagra-search-default`, `hnsw-single`, and `cagra-search-single`. The segment cases
100+
cover 1-segment indexes, 10-segment
100101
indexes, 10 segments force-merged to 1, and 100 segments force-merged to 10 for
101102
both HNSW and CAGRA. The CPU HNSW cases force the accelerated HNSW codec through
102103
its Lucene CPU fallback path in the same run, including 10 segments force-merged
@@ -107,13 +108,31 @@ documents and 32 dimensions; high-segment cases use at least 257 rows per
107108
segment to avoid expected cuVS graph-degree clamps on tiny per-segment datasets.
108109
The suite checks Lucene SPI discovery, jar packaging, index file suffixes
109110
(`.vex`/`.vem` for HNSW and `.vcag`/`.vemc` for CAGRA), indexed vector metadata,
110-
unfiltered KNN, filtered KNN, missing-vector documents, deletions, and force
111+
unfiltered KNN, filtered KNN, exact self-match at rank 1, duplicate result
112+
checks, missing-vector documents, deletions, and force
111113
merge behavior. To run a subset or resize the test:
112114

113115
```sh
114116
./test_pylucene.sh --gpu-e2e --cases=gpu-segments --rows=5000 --dims=64 --topk=20
115117
```
116118

119+
To verify that the selected pytest cases can fail quickly without initializing
120+
the JVM or touching the GPU:
121+
122+
```sh
123+
MAKE_FAIL=true ./test_pylucene.sh --gpu-e2e --cases=all --no-build
124+
```
125+
126+
The PyLucene tests live under `src/test/python` rather than `examples` so they
127+
follow the same test-vs-example separation used in cuVS/RAPIDS. cuVS keeps
128+
language-specific tests close to their owning language package or build system:
129+
Java integration tests live under `java/cuvs-java/src/test/java` and run through
130+
Maven/Failsafe, Python tests live under `python/cuvs/cuvs/tests` and run through
131+
pytest, and C/C++ tests live under `c/tests` and `cpp/tests`. This repository is
132+
Java-first, so the PyLucene pytest suite is treated as test code under
133+
`src/test/python`, while `test_pylucene.sh` owns the Maven build, jar validation,
134+
classpath setup, and pytest invocation.
135+
117136
### Running Tests
118137

119138
```sh
Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@
77
from dataclasses import dataclass
88
from pathlib import Path
99

10-
import lucene
1110

12-
13-
REPO_ROOT = Path(__file__).resolve().parents[2]
11+
REPO_ROOT = Path(__file__).resolve().parents[3]
1412
HNSW_CODEC = "Lucene101AcceleratedHNSWCodec"
1513
CAGRA_HNSW_BASE_LAYER_CODEC = "Lucene101AcceleratedHNSWBaseLayerCodec"
1614
CAGRA_HNSW_MULTI_LAYER_CODEC = "Lucene101AcceleratedHNSWMultiLayerCodec"
@@ -28,16 +26,29 @@
2826
"Lucene101AcceleratedHNSWBinaryQuantizedCodec",
2927
"Lucene101AcceleratedHNSWScalarQuantizedCodec",
3028
)
31-
BASIC_GPU_CASES = ("hnsw", "cagra", "hnsw-single", "cagra-single")
29+
CAGRA_SEARCH_CASES = (
30+
"cagra-search-default",
31+
"cagra-search-single",
32+
"cagra-search-1seg",
33+
"cagra-search-10seg",
34+
"cagra-search-10seg-force-1",
35+
"cagra-search-100seg-force-10",
36+
)
37+
BASIC_GPU_CASES = (
38+
"hnsw",
39+
"cagra-search-default",
40+
"hnsw-single",
41+
"cagra-search-single",
42+
)
3243
SEGMENT_GPU_CASES = (
3344
"hnsw-1seg",
34-
"cagra-1seg",
45+
"cagra-search-1seg",
3546
"hnsw-10seg",
36-
"cagra-10seg",
47+
"cagra-search-10seg",
3748
"hnsw-10seg-force-1",
38-
"cagra-10seg-force-1",
49+
"cagra-search-10seg-force-1",
3950
"hnsw-100seg-force-10",
40-
"cagra-100seg-force-10",
51+
"cagra-search-100seg-force-10",
4152
)
4253
CPU_HNSW_CASES = (
4354
"hnsw-cpu",
@@ -55,13 +66,15 @@
5566
"hnsw-cpu",
5667
"cagra-hnsw-1layer",
5768
"cagra-hnsw-3layer",
58-
"cagra",
69+
"cagra-search-default",
5970
)
6071
CASE_GROUPS = {
6172
"gpu": BASIC_GPU_CASES,
6273
"gpu-basic": BASIC_GPU_CASES,
6374
"gpu-segments": SEGMENT_GPU_CASES,
6475
"segments": SEGMENT_GPU_CASES,
76+
"cagra-search": CAGRA_SEARCH_CASES,
77+
"gpu-cagra-search": CAGRA_SEARCH_CASES,
6578
"cpu-hnsw": CPU_HNSW_CASES,
6679
"cagra-hnsw": CAGRA_HNSW_CASES,
6780
"algorithm-matrix": ALGORITHM_MATRIX_CASES,
@@ -206,6 +219,8 @@ def fvec(jarray, values):
206219

207220

208221
def init_vm(cuvs_java_jar, cuvs_lucene_jar):
222+
import lucene
223+
209224
java_library_path = os.environ.get("JAVA_LIBRARY_PATH") or os.environ.get(
210225
"LD_LIBRARY_PATH"
211226
)
@@ -385,9 +400,9 @@ def build_named_case(name):
385400
segment_count=3,
386401
force_merge_target=(1 if force_merge else 0),
387402
)
388-
if name == "cagra":
403+
if name in {"cagra", "cagra-search", "cagra-search-default"}:
389404
return matrix_case(
390-
name="cagra",
405+
name="cagra-search-default",
391406
codec_name=CAGRA_CODEC,
392407
expected_suffixes=(".vcag", ".vemc"),
393408
require_cuvs=True,
@@ -405,9 +420,9 @@ def build_named_case(name):
405420
expected_suffixes=(".vex", ".vem"),
406421
require_cuvs=require_cuvs,
407422
)
408-
if name in {"cagra-single", "single-cagra"}:
423+
if name in {"cagra-single", "single-cagra", "cagra-search-single"}:
409424
return SmokeCase(
410-
name="cagra-single",
425+
name="cagra-search-single",
411426
codec_name=CAGRA_CODEC,
412427
row_count=1,
413428
dims=matrix_dims,
@@ -417,20 +432,33 @@ def build_named_case(name):
417432
)
418433
if name == "hnsw-1seg":
419434
return build_segment_case(name, HNSW_CODEC, (".vex", ".vem"), require_cuvs, 1, 0)
420-
if name == "cagra-1seg":
421-
return build_segment_case(name, CAGRA_CODEC, (".vcag", ".vemc"), True, 1, 0)
435+
if name in {"cagra-1seg", "cagra-search-1seg"}:
436+
return build_segment_case(
437+
"cagra-search-1seg", CAGRA_CODEC, (".vcag", ".vemc"), True, 1, 0
438+
)
422439
if name == "hnsw-10seg":
423440
return build_segment_case(name, HNSW_CODEC, (".vex", ".vem"), require_cuvs, 10, 0)
424-
if name == "cagra-10seg":
425-
return build_segment_case(name, CAGRA_CODEC, (".vcag", ".vemc"), True, 10, 0)
441+
if name in {"cagra-10seg", "cagra-search-10seg"}:
442+
return build_segment_case(
443+
"cagra-search-10seg", CAGRA_CODEC, (".vcag", ".vemc"), True, 10, 0
444+
)
426445
if name == "hnsw-10seg-force-1":
427446
return build_segment_case(name, HNSW_CODEC, (".vex", ".vem"), require_cuvs, 10, 1)
428-
if name == "cagra-10seg-force-1":
429-
return build_segment_case(name, CAGRA_CODEC, (".vcag", ".vemc"), True, 10, 1)
447+
if name in {"cagra-10seg-force-1", "cagra-search-10seg-force-1"}:
448+
return build_segment_case(
449+
"cagra-search-10seg-force-1", CAGRA_CODEC, (".vcag", ".vemc"), True, 10, 1
450+
)
430451
if name == "hnsw-100seg-force-10":
431452
return build_segment_case(name, HNSW_CODEC, (".vex", ".vem"), require_cuvs, 100, 10)
432-
if name == "cagra-100seg-force-10":
433-
return build_segment_case(name, CAGRA_CODEC, (".vcag", ".vemc"), True, 100, 10)
453+
if name in {"cagra-100seg-force-10", "cagra-search-100seg-force-10"}:
454+
return build_segment_case(
455+
"cagra-search-100seg-force-10",
456+
CAGRA_CODEC,
457+
(".vcag", ".vemc"),
458+
True,
459+
100,
460+
10,
461+
)
434462
if name in {"cagra-hnsw-1layer", "cagra-hnsw-base", "cagra-hnsw-base-layer"}:
435463
return matrix_case(
436464
name="cagra-hnsw-1layer",
@@ -767,22 +795,54 @@ def hit_ids(stored_fields, hits):
767795
return [stored_fields.document(hit.doc).get(ID_FIELD) for hit in hits]
768796

769797

770-
def assert_search_results(searcher, jarray, case, query_ids, inactive_ids):
798+
def squared_l2(left, right):
799+
return sum((a - b) * (a - b) for a, b in zip(left, right))
800+
801+
802+
def exact_neighbor_doc_names(query_id, active_vector_ids, dims, limit):
803+
query_vector = vector_for(query_id, dims)
804+
ranked = sorted(
805+
active_vector_ids,
806+
key=lambda doc_id: (squared_l2(query_vector, vector_for(doc_id, dims)), doc_id),
807+
)
808+
return [f"doc-{doc_id}" for doc_id in ranked[:limit]]
809+
810+
811+
def assert_search_results(searcher, jarray, case, query_ids, active_vector_ids, inactive_ids):
771812
from org.apache.lucene.search import KnnFloatVectorQuery
772813

773814
stored_fields = searcher.storedFields()
774-
top_k = min(case.top_k, max(1, case.row_count - len(inactive_ids)))
815+
top_k = min(case.top_k, max(1, len(active_vector_ids)))
775816
for query_id in query_ids:
776817
query = KnnFloatVectorQuery(
777818
VECTOR_FIELD, fvec(jarray, vector_for(query_id, case.dims)), top_k
778819
)
779820
ids = hit_ids(stored_fields, searcher.search(query, top_k).scoreDocs)
780821
expected = f"doc-{query_id}"
781-
if expected not in ids:
782-
raise AssertionError(f"{case.name}: expected {expected} in top {top_k}, got {ids}")
822+
if len(ids) != top_k:
823+
raise AssertionError(
824+
f"{case.name}: expected {top_k} search results, got {len(ids)}: {ids}"
825+
)
826+
if len(ids) != len(set(ids)):
827+
raise AssertionError(f"{case.name}: duplicate docs returned: {ids}")
828+
if ids[0] != expected:
829+
raise AssertionError(f"{case.name}: expected {expected} at rank 1, got {ids}")
783830
bad_ids = [doc_id for doc_id in ids if doc_id in inactive_ids]
784831
if bad_ids:
785832
raise AssertionError(f"{case.name}: inactive docs returned: {bad_ids}")
833+
expected_candidates = set(
834+
exact_neighbor_doc_names(
835+
query_id, active_vector_ids, case.dims, min(len(active_vector_ids), top_k * 2 + 10)
836+
)
837+
)
838+
unexpected_ids = [
839+
doc_id for doc_id in ids[: min(5, len(ids))] if doc_id not in expected_candidates
840+
]
841+
if unexpected_ids:
842+
raise AssertionError(
843+
f"{case.name}: nearest hits {unexpected_ids} were outside the exact "
844+
f"top {len(expected_candidates)} candidates for {expected}"
845+
)
786846

787847

788848
def assert_filtered_search(searcher, jarray, case, query_id):
@@ -906,7 +966,9 @@ def run_case(case, codec_class, codec_cache, jarray):
906966
assert_vector_metadata(reader, VECTOR_FIELD, expected_vector_count, case.dims)
907967
assert_segment_topology(reader, case)
908968
searcher = IndexSearcher(reader)
909-
assert_search_results(searcher, jarray, case, query_ids, inactive_doc_names)
969+
assert_search_results(
970+
searcher, jarray, case, query_ids, active_vector_ids, inactive_doc_names
971+
)
910972
assert_filtered_search(searcher, jarray, case, query_ids[0])
911973
telemetry = writer_telemetry(codec)
912974
writer_path = observed_writer_path(case, telemetry)
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import os
5+
46
import pytest
57

68
from pylucene_smoke import SELECTED_CASES, initialize_pylucene_context, run_case
79

810

11+
def make_fail_requested():
12+
return os.environ.get("MAKE_FAIL", "false").lower() in {"1", "true", "yes", "on"}
13+
14+
915
@pytest.fixture(scope="session")
1016
def pylucene_context(request):
1117
context = initialize_pylucene_context()
@@ -20,7 +26,11 @@ def pylucene_context(request):
2026

2127

2228
@pytest.mark.parametrize("case", SELECTED_CASES, ids=lambda case: case.name)
23-
def test_pylucene_smoke_case(pylucene_context, case):
29+
def test_pylucene_smoke_case(request, case):
30+
if make_fail_requested():
31+
pytest.fail(f"MAKE_FAIL=true requested failure for {case.name}")
32+
33+
pylucene_context = request.getfixturevalue("pylucene_context")
2434
run_case(
2535
case,
2636
pylucene_context.codec_class,

test_pylucene.sh

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ PYLUCENE_ROWS="${CUVS_LUCENE_PYLUCENE_ROWS:-}"
1717
PYLUCENE_DIMS="${CUVS_LUCENE_PYLUCENE_DIMS:-}"
1818
PYLUCENE_TOPK="${CUVS_LUCENE_PYLUCENE_TOPK:-}"
1919
CUVS_LUCENE_JAR_PATH="${CUVS_LUCENE_JAR:-}"
20+
MAKE_FAIL_VALUE="${MAKE_FAIL:-false}"
21+
MAKE_FAIL_ENABLED=0
22+
case "${MAKE_FAIL_VALUE}" in
23+
1|true|TRUE|True|yes|YES|Yes|on|ON|On)
24+
MAKE_FAIL_ENABLED=1
25+
;;
26+
esac
2027

2128
for arg in "$@"; do
2229
case "${arg}" in
@@ -44,13 +51,16 @@ for arg in "$@"; do
4451
echo "Builds and checks the standard cuvs-lucene jar, then runs the PyLucene pytest suite."
4552
echo "Set CUVS_LUCENE_JAR to test an existing cuvs-lucene jar."
4653
echo "Set CUVS_LUCENE_CUVS_JAVA_JAR to the base cuvs-java jar if it is not in ~/.m2."
54+
echo "Set MAKE_FAIL=true to make every selected pytest case fail before JVM setup."
4755
echo "Set PYTHON or MVN to override the Python or Maven executable."
4856
echo
49-
echo "Case groups: gpu-basic, gpu-segments, cpu-hnsw, cagra-hnsw, algorithm-matrix, all."
50-
echo "Core cases: smoke, hnsw, cagra, hnsw-single, cagra-single."
51-
echo "Segment cases: hnsw-1seg, cagra-1seg, hnsw-10seg, cagra-10seg,"
52-
echo " hnsw-10seg-force-1, cagra-10seg-force-1,"
53-
echo " hnsw-100seg-force-10, cagra-100seg-force-10."
57+
echo "Case groups: gpu-basic, gpu-segments, cagra-search, cpu-hnsw,"
58+
echo " cagra-hnsw, algorithm-matrix, all."
59+
echo "Core cases: smoke, hnsw, cagra-search-default, hnsw-single, cagra-search-single."
60+
echo "Segment cases: hnsw-1seg, cagra-search-1seg, hnsw-10seg,"
61+
echo " cagra-search-10seg, hnsw-10seg-force-1,"
62+
echo " cagra-search-10seg-force-1, hnsw-100seg-force-10,"
63+
echo " cagra-search-100seg-force-10."
5464
echo "CPU HNSW cases: hnsw-cpu, hnsw-cpu-single, hnsw-cpu-1seg,"
5565
echo " hnsw-cpu-10seg, hnsw-cpu-10seg-force-1,"
5666
echo " hnsw-cpu-100seg-force-10."
@@ -259,11 +269,13 @@ for provider in \
259269
}
260270
done
261271

262-
"${PYTHON_BIN}" -c "import lucene" >/dev/null 2>&1 || {
263-
echo "Python cannot import PyLucene's lucene module." >&2
264-
echo "Activate or install a PyLucene environment compatible with this project's Lucene version." >&2
265-
exit 1
266-
}
272+
if [[ "${MAKE_FAIL_ENABLED}" -eq 0 ]]; then
273+
"${PYTHON_BIN}" -c "import lucene" >/dev/null 2>&1 || {
274+
echo "Python cannot import PyLucene's lucene module." >&2
275+
echo "Activate or install a PyLucene environment compatible with this project's Lucene version." >&2
276+
exit 1
277+
}
278+
fi
267279

268280
"${PYTHON_BIN}" -m pytest --version >/dev/null 2>&1 || {
269281
echo "Python cannot run pytest." >&2
@@ -274,6 +286,7 @@ done
274286
smoke_env=(
275287
"CUVS_LUCENE_JAR=${cuvs_lucene_jar_abs}"
276288
"CUVS_LUCENE_CUVS_JAVA_JAR=${cuvs_java_jar}"
289+
"MAKE_FAIL=${MAKE_FAIL_VALUE}"
277290
)
278291

279292
if [[ "${GPU_E2E}" -eq 1 ]]; then
@@ -301,4 +314,4 @@ else
301314
smoke_env+=("CUVS_LUCENE_VERIFY_ALL_CODECS=${CUVS_LUCENE_VERIFY_ALL_CODECS:-1}")
302315
fi
303316

304-
env "${smoke_env[@]}" "${PYTHON_BIN}" -m pytest -q -s examples/Python/test_pylucene_smoke.py
317+
env "${smoke_env[@]}" "${PYTHON_BIN}" -m pytest -q -s src/test/python/test_pylucene_smoke.py

0 commit comments

Comments
 (0)