Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 34 additions & 31 deletions tests/python/test_array_alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,52 +46,55 @@ def test_indexes_accept_unaligned_build_and_query_arrays(kind, offset, tmp_path)

def make_index():
if kind == "ivf":
return IvfIndex(65, len(data), 2, 4)
return IvfIndex(65, 2, nbits=4, seed=42)
if kind == "hnsw":
return HnswIndex(
65, len(data), M=8, ef_construction=32, nbits=4, random_seed=42
65, num_clusters=2, M=8, ef_construction=32, nbits=4, seed=42
)
return SymqgIndex(65, 32)
return SymqgIndex(65, max_degree=32, ef_construction=32, seed=42)

def fill(index, vectors, centers):
if kind == "qg":
index.add(vectors, num_threads=1)
else:
index.set_centroids(centers)
index.add(vectors, cluster_ids=labels, num_threads=1)

if kind == "ivf":
parameters = {"nprobe": 2}
else:
parameters = {"ef": 65}

if kind != "qg":
reference = make_index()
reference.build(data, centroids, labels, num_threads=1)
reference_path = tmp_path / "aligned.index"
reference.save(str(reference_path))
expected_bytes = reference_path.read_bytes()
expected = reference.search(queries, k=5, num_threads=1, **parameters)
# Explicit seeds and one build thread make every index reproducible.
reference = make_index()
fill(reference, data, centroids)
reference_path = tmp_path / "aligned.index"
reference.save(str(reference_path))
expected_bytes = reference_path.read_bytes()
expected = reference.search(queries, k=5, num_threads=1, **parameters)
if kind == "qg":
# SymphonyQG stores raw vectors, so its distances are exact.
exact = np.sum(
(queries.astype(np.float64)[:, None] - data.astype(np.float64)) ** 2,
axis=2,
)
np.testing.assert_allclose(
expected[1],
np.take_along_axis(exact, expected[0], axis=1),
rtol=2e-6,
atol=1e-5,
)

# Isolate each conversion so a damaged centroid cannot hide behind data damage.
inputs = [(unaligned_data, centroids)]
if kind != "qg":
inputs += [(data, unaligned_centroids), (unaligned_data, unaligned_centroids)]
for vectors, centers in inputs:
# IVF samples a rotation at construction; rebuild a loaded reference to
# reuse it. HNSW's explicit seed reproduces its rotation and serial graph.
index = IvfIndex.load(str(reference_path)) if kind == "ivf" else make_index()
if kind == "qg":
index.build(vectors, ef_construction=32, num_threads=1)
ids, distances = index.search(queries, k=len(data), **parameters)
for row in ids:
np.testing.assert_array_equal(np.sort(row), np.arange(len(data)))
exact = np.sum(
(queries.astype(np.float64)[:, None] - data.astype(np.float64)) ** 2,
axis=2,
)
np.testing.assert_allclose(
distances, np.take_along_axis(exact, ids, axis=1), rtol=2e-6, atol=1e-5
)
expected = index.search(queries, k=5, num_threads=1, **parameters)
else:
index.build(vectors, centers, labels, num_threads=1)
actual_path = tmp_path / "unaligned.index"
index.save(str(actual_path))
assert actual_path.read_bytes() == expected_bytes
index = make_index()
fill(index, vectors, centers)
actual_path = tmp_path / "unaligned.index"
index.save(str(actual_path))
assert actual_path.read_bytes() == expected_bytes
for values in (queries, unaligned_queries):
actual = index.search(values, k=5, num_threads=1, **parameters)
for result, reference_result in zip(actual, expected, strict=True):
Expand Down
32 changes: 17 additions & 15 deletions tests/python/test_build_cluster_ids.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Build must validate original assignments before narrowing them to point IDs."""
"""`add` must validate original assignments before narrowing them to point IDs."""

import numpy as np
import pytest
Expand All @@ -10,33 +10,35 @@
"bad",
[2**32, -(2**32), 0.5, -1, 1, np.uint64(2**64 - 1), True, "0"],
)
def test_build_rejects_invalid_cluster_ids(kind, bad):
def test_add_rejects_invalid_cluster_ids(kind, bad):
data = np.ones((2, 64), dtype=np.float32)
centroids = np.zeros((1, 64), dtype=np.float32)
index = (
IvfIndex(64, 2, 1, 4)
IvfIndex(64, 1, nbits=4)
if kind == "ivf"
else HnswIndex(64, 2, M=2, ef_construction=10, nbits=4)
else HnswIndex(64, num_clusters=1, M=2, ef_construction=10, nbits=4)
)
index.set_centroids(centroids)
ids = np.full(2, bad)
with pytest.raises(ValueError, match="cluster_ids"):
index.build(data, centroids, ids)
assert not index.is_built
index.build(data, centroids, np.zeros(2, dtype=np.int64))
assert index.is_built
index.add(data, cluster_ids=ids, num_threads=1)
assert len(index) == 0
index.add(data, cluster_ids=np.zeros(2, dtype=np.int64), num_threads=1)
assert len(index) == 2


@pytest.mark.parametrize("kind", ["ivf", "hnsw"])
@pytest.mark.parametrize("layout", ["native", "strided", "swapped", "unaligned"])
def test_build_uint32_cluster_ids_validate_and_preserve_input(kind, layout):
def test_add_uint32_cluster_ids_validate_and_preserve_input(kind, layout):
data = np.ones((2, 64), dtype=np.float32)
data[1] = 2
centroids = data.copy()
index = (
IvfIndex(64, 2, 2, 4)
IvfIndex(64, 2, nbits=4)
if kind == "ivf"
else HnswIndex(64, 2, M=2, ef_construction=10, nbits=4)
else HnswIndex(64, num_clusters=2, M=2, ef_construction=10, nbits=4)
)
index.set_centroids(centroids)
if layout == "strided":
ids = np.zeros(4, dtype=np.uint32)[::2]
elif layout == "swapped":
Expand All @@ -47,11 +49,11 @@ def test_build_uint32_cluster_ids_validate_and_preserve_input(kind, layout):
ids = np.zeros(2, dtype=np.uint32)
ids[1] = np.iinfo(np.uint32).max
with pytest.raises(ValueError, match="cluster_ids"):
index.build(data, centroids, ids, num_threads=1)
assert not index.is_built
index.add(data, cluster_ids=ids, num_threads=1)
assert len(index) == 0
ids[1] = 1
ids.flags.writeable = False
before = ids.copy()
index.build(data, centroids, ids, num_threads=1)
assert index.is_built
index.add(data, cluster_ids=ids, num_threads=1)
assert len(index) == 2
np.testing.assert_array_equal(ids, before)
86 changes: 52 additions & 34 deletions tests/python/test_concurrent_indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,41 @@
("ivf_flat_rabitq", 4),
("hnsw", 1),
("hnsw", 9),
("qg", 0),
("qg", 32),
("qg", 4),
("qg", 8),
]


def make_index(kind, bits=4, metric="l2"):
def make_index(kind, bits=4, metric="l2", fill=True):
rng = np.random.default_rng(713)
data = rng.standard_normal((129, 65)).astype(np.float32)
labels = (np.arange(len(data)) % 5).astype(np.uint32)
centroids = np.stack([data[labels == i].mean(axis=0) for i in range(5)])
if kind.startswith("ivf"):
initializer = kind.removeprefix("ivf_") if "_" in kind else "flat"
index = IvfIndex(65, len(data), 5, bits, metric, initializer)
index = IvfIndex(65, 5, nbits=bits, metric=metric, initializer=initializer)
elif kind == "hnsw":
index = HnswIndex(65, len(data) + 8, 8, 64, bits, metric, 42)
index = HnswIndex(
65,
num_clusters=5,
M=8,
ef_construction=64,
nbits=bits,
metric=metric,
seed=42,
)
else:
index = SymqgIndex(65, 32, metric, bits)

def build(vectors=data):
index = SymqgIndex(
65, max_degree=32, ef_construction=64, metric=metric, nbits=bits
)
if fill:
if kind == "qg":
index.build(vectors, 64, 1)
index.add(data, num_threads=1)
else:
index.build(vectors, centroids, labels, 1)

build()
return index, data, build
index.set_centroids(centroids)
index.add(data, cluster_ids=labels, num_threads=1)
return index, data


def parameters(kind, wide):
Expand All @@ -56,7 +64,7 @@ def parameters(kind, wide):
@pytest.mark.parametrize("kind,bits", CASES)
@pytest.mark.parametrize("metric", ["l2", "ip"])
def test_concurrent_search_matches_serial_and_loaded(kind, bits, metric, tmp_path):
index, _, _ = make_index(kind, bits, metric)
index, _ = make_index(kind, bits, metric)
# Exercise conversions and ownership of a temporary contiguous float32 copy.
queries = np.random.default_rng(18).standard_normal((17, 130))[:, ::2]
expected = [index.search(queries, 5, **parameters(kind, wide)) for wide in (0, 1)]
Expand All @@ -69,12 +77,7 @@ def search(worker):
wide = worker % 2
start.wait(timeout=10)
for count in (0, 1, 17, 17):
method = (
current.search_batch
if kind == "qg" and worker >= 2
else current.search
)
actual = method(
actual = current.search(
queries[:count],
5,
num_threads=1 + worker // 2,
Expand Down Expand Up @@ -104,7 +107,7 @@ def __array__(self, dtype=None, copy=None):

@pytest.mark.parametrize("kind", ["ivf", "hnsw", "qg"])
def test_active_search_allows_reads_and_rejects_mutations(kind, tmp_path):
index, data, build = make_index(kind)
index, data = make_index(kind)
queries = PausedArray(data[:3])
summary = repr(index)
options = parameters(kind, True)
Expand All @@ -116,9 +119,13 @@ def test_active_search_allows_reads_and_rejects_mutations(kind, tmp_path):
assert repr(index) == summary
index.search(data[:1], 5, **options)
index.save(str(tmp_path / "reading.index"))
mutations = [build]
mutations = [lambda: index.add(data[:1])]
if kind != "qg":
mutations += [lambda: index.add(data[:1]), lambda: index.remove([0])]
mutations += [
lambda: index.remove([0]),
lambda: index.train(data),
lambda: index.set_centroids(data[:5]),
]
if kind == "hnsw":
mutations.append(lambda: index.resize(256))
for mutate in mutations:
Expand All @@ -130,47 +137,56 @@ def test_active_search_allows_reads_and_rejects_mutations(kind, tmp_path):
# Failed conflicts must not leave the access state busy.
if kind == "hnsw":
index.resize(256)
elif kind == "qg":
# add takes write access before it rejects a second graph.
with pytest.raises(RuntimeError, match="does not support incremental addition"):
index.add(data)
else:
build()
index.add(data[:1])
index.search(data[:1], 5, **options)


@pytest.mark.parametrize("kind", ["ivf", "hnsw", "qg"])
def test_active_mutation_rejects_reads_and_other_mutations(kind, tmp_path):
index, data, build = make_index(kind)
vectors = PausedArray(data[:1] if kind == "hnsw" else data)
# SymphonyQG adds all vectors at once, so its index starts empty.
index, data = make_index(kind, fill=kind != "qg")
vectors = PausedArray(data if kind == "qg" else data[:1])
with ThreadPoolExecutor(max_workers=1) as pool:
running = pool.submit(index.add if kind == "hnsw" else build, vectors)
running = pool.submit(index.add, vectors, num_threads=1)
try:
assert vectors.entered.wait(timeout=10)
calls = [
lambda: index.search(data[:1], 5, **parameters(kind, True)),
lambda: index.save(str(tmp_path / "busy.index")),
lambda: index.dim,
lambda: index.is_built,
lambda: len(index),
lambda: repr(index),
lambda: str(index),
build,
lambda: index.add(data[:1]),
]
if kind != "qg":
calls += [lambda: index.add(data[:1]), lambda: index.remove([0])]
calls += [
lambda: index.remove([0]),
lambda: index.train(data),
lambda: index.set_centroids(data[:5]),
]
if kind == "hnsw":
calls.append(lambda: index.resize(256))
for call in calls:
with pytest.raises(RuntimeError, match=f"^{BUSY}$"):
call()
other, other_data, _ = make_index(kind)
other, other_data = make_index(kind)
other.search(other_data[:1], 5, **parameters(kind, True))
finally:
vectors.resume.set()
running.result(timeout=10)
assert index.is_built
assert len(index) == (len(data) if kind == "qg" else len(data) + 1)
index.search(data[:1], 5, **parameters(kind, True))


@pytest.mark.parametrize("kind", ["ivf", "hnsw", "qg"])
def test_search_releases_gil(kind):
index, data, _ = make_index(kind)
index, data = make_index(kind)
queries = np.tile(data, (32, 1))
ready, progress = Event(), Event()
active = stopped = False
Expand Down Expand Up @@ -208,12 +224,14 @@ def python_worker():


def test_native_exception_reacquires_gil_and_releases_access():
index, data, build = make_index("qg")
index, data = make_index("qg")
queries = data[:2].copy()
queries[1, 0] = np.nan
# The native search checks coordinates after releasing the GIL.
with pytest.raises(ValueError, match="must contain only finite values"):
index.search(queries, 5, ef=64)
build()
# add takes write access before it rejects a second graph.
with pytest.raises(RuntimeError, match="does not support incremental addition"):
index.add(data)
ids, distances = index.search(data[:2], 5, ef=64)
assert ids.shape == distances.shape == (2, 5)
Loading
Loading