# Copyright 2025-present the zvec project # # Licensed 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. """ Tests for the ``use_contiguous_memory`` optimization on ``HnswIndexParam``. The HNSW streamer supports two allocation strategies for graph nodes: * ``use_contiguous_memory=False`` (default): each node allocates its own linked buffer. Lower peak memory usage, worse cache locality. * ``use_contiguous_memory=True``: a single contiguous arena holds every node. Higher peak memory usage, better cache locality and search throughput. These tests exercise the Python surface end-to-end and make sure that when a collection is created / reopened with ``use_contiguous_memory=True`` the underlying HNSW streamer entity is constructed correctly and serves search traffic. """ from __future__ import annotations import pickle import sys import numpy as np import pytest import zvec from zvec import ( Collection, CollectionOption, CollectionSchema, Doc, FieldSchema, HnswIndexParam, HnswQueryParam, InvertIndexParam, Query, VectorSchema, ) from zvec.typing import DataType, IndexType, MetricType, QuantizeType DIMENSION = 32 NUM_DOCS = 128 TOPK = 5 # --------------------------------------------------------------------------- def _debug_hnsw_storage_mode(coll: Collection, column: str = "dense") -> str: """Return the internal HNSW entity storage mode for ``column``. Exposes the debug-only introspection hook on the pybind11 ``_Collection``. Only meaningful after ``optimize()`` has built a persisted HNSW index; on a pure writing segment it will raise ``KeyError``. """ underlying = coll._obj # type: ignore[attr-defined] return underlying._debug_hnsw_storage_mode(column) def _build_schema(name: str, *, use_contiguous_memory: bool) -> CollectionSchema: """Create a simple schema with a single FP32 HNSW vector column.""" return CollectionSchema( name=name, fields=[ FieldSchema( "id", DataType.INT64, nullable=False, index_param=InvertIndexParam(enable_range_optimization=True), ), ], vectors=[ VectorSchema( "dense", DataType.VECTOR_FP32, dimension=DIMENSION, index_param=HnswIndexParam( metric_type=MetricType.IP, m=16, ef_construction=100, use_contiguous_memory=use_contiguous_memory, ), ), ], ) def _generate_docs(rng: np.random.Generator, num: int = NUM_DOCS) -> list[Doc]: """Produce deterministic documents for insertion.""" docs: list[Doc] = [] for i in range(num): vec = rng.standard_normal(DIMENSION).astype(np.float32) docs.append( Doc( id=str(i), fields={"id": i}, vectors={"dense": vec.tolist()}, ) ) return docs def _assert_query_matches(coll: Collection, query_vec: list[float]) -> list[str]: """Run a top-k vector query and return the returned ids in order.""" vector_query = Query( field_name="dense", vector=query_vec, param=HnswQueryParam(ef=128), ) hits = coll.query(vector_query, topk=TOPK) # Expect a single result group for the single vector query. assert hits is not None, "query returned None" assert len(hits) >= 1, f"expected at least one hit, got {hits!r}" return [doc.id for doc in hits] # --------------------------------------------------------------------------- # 1) Pure Python surface: construction / property / to_dict / repr / pickle # --------------------------------------------------------------------------- class TestHnswIndexParamContiguousMemorySurface: """Verify the Python binding exposes ``use_contiguous_memory`` correctly.""" def test_default_is_false(self): param = HnswIndexParam() assert param.use_contiguous_memory is False def test_custom_true(self): param = HnswIndexParam(use_contiguous_memory=True) assert param.use_contiguous_memory is True assert param.type == IndexType.HNSW # other fields keep their default values assert param.m == 50 assert param.ef_construction == 500 def test_to_dict_includes_use_contiguous_memory(self): param = HnswIndexParam( metric_type=MetricType.L2, m=16, ef_construction=100, quantize_type=QuantizeType.FP16, use_contiguous_memory=True, ) data = param.to_dict() assert data["use_contiguous_memory"] is True # Make sure existing fields are still present. assert data["metric_type"] == "L2" assert data["m"] == 16 assert data["ef_construction"] == 100 assert data["quantize_type"] == "FP16" def test_repr_contains_flag(self): on = repr(HnswIndexParam(use_contiguous_memory=True)) off = repr(HnswIndexParam(use_contiguous_memory=False)) assert "use_contiguous_memory" in on assert "use_contiguous_memory" in off assert "true" in on assert "false" in off def test_readonly_property(self): param = HnswIndexParam(use_contiguous_memory=True) if sys.version_info >= (3, 11): match_pattern = r"(can't set attribute|has no setter|readonly attribute)" else: match_pattern = r"can't set attribute" with pytest.raises(AttributeError, match=match_pattern): param.use_contiguous_memory = False # type: ignore[misc] def test_pickle_roundtrip(self): original = HnswIndexParam( metric_type=MetricType.COSINE, m=24, ef_construction=150, quantize_type=QuantizeType.INT8, use_contiguous_memory=True, ) restored = pickle.loads(pickle.dumps(original)) assert restored.use_contiguous_memory is True assert restored.metric_type == MetricType.COSINE assert restored.m == 24 assert restored.ef_construction == 150 assert restored.quantize_type == QuantizeType.INT8 # --------------------------------------------------------------------------- # 2) End-to-end: create collection, insert, query with contiguous memory on # --------------------------------------------------------------------------- @pytest.fixture def rng() -> np.random.Generator: return np.random.default_rng(seed=42) # NOTE: the ``enable_mmap=False`` (BufferPool) variant is intentionally # omitted from this fixture. Building a persisted HNSW index via # ``optimize()`` / ``create_vector_index`` / ``drop_vector_index`` # currently requires mmap-backed storage, because the BufferPool backend # has not implemented the ``create_new`` semantics yet and the guard in # ``SegmentImpl::merge_vector_indexer`` rejects that combination. Once # BufferPool gains write support, re-add ``False`` to ``params`` (and # drop the guard in segment.cc) so these end-to-end tests cover both # storage modes again. @pytest.fixture(params=[True], ids=["mmap_on"]) def collection_option(request) -> CollectionOption: return CollectionOption(read_only=False, enable_mmap=request.param) # Building a new persisted HNSW index currently requires mmap-backed storage # because the BufferPool backend has not implemented `create_new` semantics # yet. Collections opened with ``enable_mmap=False`` therefore cannot run # optimize()/create_vector_index/drop_vector_index. Tests use this fixture # to know which behaviour to assert, and once BufferPool gains write support # the guard in segment.cc (and these branches) can be removed together. @pytest.fixture def build_index_supported(collection_option: CollectionOption) -> bool: return bool(collection_option.enable_mmap) # Error message fragments emitted by the NotSupported guard in # SegmentImpl::merge_vector_indexer / drop_vector_index. If the C++ message # changes, update these together. _BUILD_NOT_SUPPORTED_FRAGMENTS = ("not yet supported", "enable_mmap=false") class TestHnswContiguousMemoryEndToEnd: """End-to-end: schema -> create_and_open -> insert -> query works.""" def test_create_with_contiguous_memory_and_query( self, tmp_path_factory, collection_option, rng, ): """With the flag on, the schema round-trips and search works end-to-end. After ``optimize()`` the writing segment is compacted into a persisted segment backed by the configured HNSW entity. We assert both the user-observable behaviour (schema + search) and, via the debug hook, that the entity type actually honours ``use_contiguous_memory``. """ schema = _build_schema("hnsw_contig_create", use_contiguous_memory=True) path = tmp_path_factory.mktemp("zvec") / "hnsw_contig_create" coll = zvec.create_and_open( path=str(path), schema=schema, option=collection_option ) try: # Schema round-trips with the flag set. vec_schema = coll.schema.vectors[0] assert vec_schema.index_param.use_contiguous_memory is True docs = _generate_docs(rng) insert_result = coll.insert(docs=docs) for r in insert_result: assert r.ok(), f"insert failed: code={r.code()}" assert coll.stats.doc_count == NUM_DOCS # Build persisted HNSW index; this is where the contiguous entity # is actually instantiated. coll.optimize() assert _debug_hnsw_storage_mode(coll) == "contiguous", ( "use_contiguous_memory=True should produce a contiguous entity" ) # Pick an existing vector as the query; top-1 must be itself. query_vec = docs[0].vector("dense") ids = _assert_query_matches(coll, query_vec) assert ids[0] == "0", f"expected self-recall, got top-1 id={ids[0]}" finally: coll.destroy() def test_create_without_contiguous_memory_uses_mmap_entity( self, tmp_path_factory, collection_option, rng, ): """Baseline: when the flag is omitted the default (mmap) entity is used.""" schema = _build_schema("hnsw_contig_default", use_contiguous_memory=False) path = tmp_path_factory.mktemp("zvec") / "hnsw_contig_default" coll = zvec.create_and_open( path=str(path), schema=schema, option=collection_option ) try: vec_schema = coll.schema.vectors[0] assert vec_schema.index_param.use_contiguous_memory is False docs = _generate_docs(rng) for r in coll.insert(docs=docs): assert r.ok() assert coll.stats.doc_count == NUM_DOCS coll.optimize() # With the flag off and mmap on, the persisted entity must be the # default mmap layout — specifically, not the contiguous arena. assert _debug_hnsw_storage_mode(coll) == "mmap", ( "use_contiguous_memory=False + enable_mmap=True should " "produce the mmap entity" ) # Search still functions with the default entity backing. query_vec = docs[0].vector("dense") ids = _assert_query_matches(coll, query_vec) assert ids[0] == "0" finally: coll.destroy() def test_close_and_reopen_with_contiguous_memory( self, tmp_path_factory, collection_option, rng, ): """Reopening a collection must preserve the ``use_contiguous_memory`` flag. The core property: the flag survives the schema persist/reload round-trip so the HNSW streamer entity — constructed lazily on first persisted-segment build — honours the user's choice. We run ``optimize()`` after reopen and confirm the contiguous entity was materialized. """ schema = _build_schema("hnsw_contig_reopen", use_contiguous_memory=True) path = tmp_path_factory.mktemp("zvec") / "hnsw_contig_reopen" path_str = str(path) created = zvec.create_and_open( path=path_str, schema=schema, option=collection_option ) docs = _generate_docs(rng) for r in created.insert(docs=docs): assert r.ok() assert created.stats.doc_count == NUM_DOCS # Persist pending writes so that reopen reconstructs state from disk. created.flush() del created # close the handle reopened = zvec.open(path=path_str, option=collection_option) try: assert reopened is not None assert reopened.stats.doc_count == NUM_DOCS # Schema persisted the flag across the reopen boundary. vec_schema = reopened.schema.vectors[0] assert vec_schema.index_param.use_contiguous_memory is True reopened.optimize() assert _debug_hnsw_storage_mode(reopened) == "contiguous" # Entity actually works: exact self-recall + fetch parity. query_vec = docs[7].vector("dense") ids = _assert_query_matches(reopened, query_vec) assert ids[0] == "7" fetched = reopened.fetch([d.id for d in docs[:10]]) assert len(fetched) == 10 finally: reopened.destroy() def test_result_parity_with_and_without_contiguous_memory( self, tmp_path_factory, rng, ): """ Two collections built from the same documents must return the same top-k neighbors regardless of whether contiguous memory is enabled: the flag is a memory-layout optimization and must not alter recall for identical graph construction parameters on the same data. """ docs = _generate_docs(rng) query_vec = docs[3].vector("dense") def _build_and_query(tag: str, flag: bool) -> list[str]: schema = _build_schema(f"hnsw_parity_{tag}", use_contiguous_memory=flag) option = CollectionOption(read_only=False, enable_mmap=True) path = tmp_path_factory.mktemp("zvec") / f"hnsw_parity_{tag}" coll = zvec.create_and_open(path=str(path), schema=schema, option=option) try: for r in coll.insert(docs=docs): assert r.ok() coll.optimize() expected_mode = "contiguous" if flag else "mmap" assert _debug_hnsw_storage_mode(coll) == expected_mode, ( f"{tag}: unexpected entity type" ) return _assert_query_matches(coll, query_vec) finally: coll.destroy() ids_off = _build_and_query("off", flag=False) ids_on = _build_and_query("on", flag=True) # The graph is built with the same (m, ef_construction, data, order), # so top-k results must match exactly. assert ids_on == ids_off, ( f"top-{TOPK} results diverged between use_contiguous_memory modes: " f"on={ids_on}, off={ids_off}" ) # Sanity: self-recall is still perfect. assert ids_on[0] == "3"