diff --git a/ggml/CMakeLists.txt b/ggml/CMakeLists.txt index 142a1f42c795..769dbed71103 100644 --- a/ggml/CMakeLists.txt +++ b/ggml/CMakeLists.txt @@ -341,6 +341,7 @@ set(GGML_PUBLIC_HEADERS include/ggml-rpc.h include/ggml-virtgpu.h include/ggml-sycl.h + include/ggml-vector-index.h include/ggml-vulkan.h include/ggml-webgpu.h include/ggml-zendnn.h diff --git a/ggml/include/ggml-vector-index.h b/ggml/include/ggml-vector-index.h new file mode 100644 index 000000000000..217c58d74696 --- /dev/null +++ b/ggml/include/ggml-vector-index.h @@ -0,0 +1,130 @@ +#pragma once +// +// ggml-vector-index: TurboQuant-style ANN vector-index C API. +// +// POC NOTE +// -------- +// This is the public C API for fabric's vector index. The implementation +// under `ggml/src/ggml-vector-index.cpp` is intentionally naive (full f32 +// storage, scalar dot-product, min-heap top-k). The C API itself is final: +// downstream Bare addon bindings + JS wrappers depend on this signature and +// the on-disk file format documented at the bottom of this header. +// Quantization / SIMD / GPU kernels are future work and will be swapped in +// behind the same API without breaking callers. +// +// Threading: instances are NOT thread-safe. Callers must serialize access +// to a given handle. Multiple handles can be used concurrently. +// +// Endianness: persistence format is fixed little-endian. The POC asserts a +// LE host; we do not currently support big-endian platforms. + +#include "ggml.h" + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Opaque handle to a vector index instance. +struct ggml_vec_index; +typedef struct ggml_vec_index ggml_vec_index_t; + +// Error codes returned from int-valued APIs. 0 = OK. Negative = failure. +// `_remove` is the exception: it returns 1 on removal and 0 on miss. +enum ggml_vec_index_error { + GGML_VEC_INDEX_OK = 0, + GGML_VEC_INDEX_E_INVALID_DIM = -1, + GGML_VEC_INDEX_E_INVALID_ARG = -2, + GGML_VEC_INDEX_E_DUPLICATE = -3, + GGML_VEC_INDEX_E_IO = -4, + GGML_VEC_INDEX_E_BAD_MAGIC = -5, + GGML_VEC_INDEX_E_BAD_VERSION = -6, + GGML_VEC_INDEX_E_OOM = -7, + GGML_VEC_INDEX_E_INTERNAL = -99, +}; + +// Lifecycle. +// +// `dim` must be > 0. `bit_width` is reserved for future quantization; the +// POC accepts any value in [1, 32] but ignores it (storage is always full +// f32). Returns NULL on bad args. +GGML_API ggml_vec_index_t * ggml_vec_index_create(int dim, int bit_width); + +GGML_API void ggml_vec_index_free(ggml_vec_index_t * idx); + +// Mutation. +// +// Adds `n` vectors of length `dim` each (row-major, contiguous in `vectors`), +// associating each with the corresponding `ids[i]` (caller-owned external id). +// Returns 0 on success. Returns GGML_VEC_INDEX_E_DUPLICATE if any id already +// exists in the index; in that case the index is unchanged (atomic add). +GGML_API int ggml_vec_index_add( + ggml_vec_index_t * idx, + const float * vectors, + int n, + const uint64_t * ids); + +// Removes the entry for `id` via swap-with-last (slot indices are NOT stable +// across removes; external ids ARE). Returns 1 if removed, 0 if not present, +// negative on error. +GGML_API int ggml_vec_index_remove(ggml_vec_index_t * idx, uint64_t id); + +// Returns 1 if the id is in the index, 0 otherwise. Read-only. +GGML_API int ggml_vec_index_contains(const ggml_vec_index_t * idx, uint64_t id); + +// No-op for the POC. Placeholder for future cache warming / codebook +// resolution after a bulk add. Reserved as a mutating op (warm-up may +// materialize derived state inside the index). +GGML_API void ggml_vec_index_prepare(ggml_vec_index_t * idx); + +// Top-k search. `queries` is `n_q * dim` row-major. `out_scores` and +// `out_ids` are caller-allocated buffers of size `n_q * k`. Each row is +// sorted descending by score (higher = closer / more similar). If the index +// holds fewer than k entries, the remaining slots in each row are filled +// with sentinel values: -FLT_MAX for scores, UINT64_MAX for ids. Read-only +// against the index (does not mutate state). +// +// Score semantics: scalar full-precision dot product. Callers that want +// cosine similarity must L2-normalize their vectors before insert AND +// before query; the index does NOT normalize internally. +GGML_API int ggml_vec_index_search( + const ggml_vec_index_t * idx, + const float * queries, + int n_q, + int k, + float * out_scores, + uint64_t * out_ids); + +// Persistence. Format is .tvim version 1; see bottom of this header. +GGML_API int ggml_vec_index_write( + ggml_vec_index_t * idx, + const char * path); + +// Returns NULL on failure (caller can inspect errno for I/O specifics). +GGML_API ggml_vec_index_t * ggml_vec_index_load(const char * path); + +// Stats. +GGML_API int ggml_vec_index_len(const ggml_vec_index_t * idx); +GGML_API int ggml_vec_index_dim(const ggml_vec_index_t * idx); +GGML_API int ggml_vec_index_bit_width(const ggml_vec_index_t * idx); + +// File format (.tvim version 1, all little-endian): +// +// offset size field +// ------ ----- ------------------------------------------------------- +// 0 4 magic = "TVPI" (bytes 0x54, 0x56, 0x50, 0x49) +// 4 1 version = 1 +// 5 1 bit_width +// 6 2 reserved (zero) +// 8 4 dim (uint32) +// 12 4 n_vectors (uint32) +// 16 N*D*4 vectors (float32, row-major) +// ... N*8 ids (uint64) +// +// Where N = n_vectors and D = dim. There is no checksum in v1; future +// versions may append one without breaking back-compat (version-gated). + +#ifdef __cplusplus +} +#endif diff --git a/ggml/src/CMakeLists.txt b/ggml/src/CMakeLists.txt index b2813b761654..f3d1cf814b0f 100644 --- a/ggml/src/CMakeLists.txt +++ b/ggml/src/CMakeLists.txt @@ -191,6 +191,7 @@ add_library(ggml-base ../include/ggml-backend.h ../include/ggml-cpp.h ../include/ggml-opt.h + ../include/ggml-vector-index.h ../include/gguf.h ../include/uint8-buff-stream.h ggml.c @@ -203,6 +204,7 @@ add_library(ggml-base ggml-threading.h ggml-quants.c ggml-quants.h + ggml-vector-index.cpp gguf.cpp uint8-buff-stream.cpp) diff --git a/ggml/src/ggml-vector-index.cpp b/ggml/src/ggml-vector-index.cpp new file mode 100644 index 000000000000..174f5c0eafe7 --- /dev/null +++ b/ggml/src/ggml-vector-index.cpp @@ -0,0 +1,419 @@ +// ggml-vector-index.cpp — POC scalar implementation of the fabric vector +// index C API declared in `ggml/include/ggml-vector-index.h`. +// +// Storage: full f32 vectors as a contiguous std::vector. ID map uses +// std::unordered_map for lookup and a parallel vector for +// the slot->id reverse map. Remove uses swap-with-last. +// +// Search: naive scalar dot product across all slots + min-heap of size k. +// No SIMD, no GPU. Correctness over speed; the optimization phase will swap +// the storage layout and kernel without touching the C API. + +#include "ggml-vector-index.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr uint8_t kTvimMagic[4] = { 'T', 'V', 'P', 'I' }; +constexpr uint8_t kTvimVersion = 1; +constexpr size_t kTvimHeaderSize = 16; + +// Top-k via min-heap of (score, id). The heap holds at most `k` candidates; +// each new score is compared against the smallest in the heap. +struct ScoreId { + float score; + uint64_t id; +}; + +struct MinHeapCmp { + bool operator()(const ScoreId & a, const ScoreId & b) const { + // Min-heap by score (smallest score at the top). + return a.score > b.score; + } +}; + +} // namespace + +// Lifetime-managed instance state. Lives behind the opaque +// `ggml_vec_index_t` typedef. +struct ggml_vec_index { + int dim = 0; + int bit_width = 32; + + // Flat row-major storage: `data[slot * dim + i]` is component i of vec slot. + std::vector data; + + // slot -> external id (parallel to logical slot index). + std::vector slot_to_id; + + // external id -> slot. + std::unordered_map id_to_slot; +}; + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +ggml_vec_index_t * ggml_vec_index_create(int dim, int bit_width) { + if (dim <= 0) { + return nullptr; + } + if (bit_width <= 0 || bit_width > 32) { + // POC ignores bit_width but still validates the range so callers + // surface bad config early. + return nullptr; + } + auto * idx = new (std::nothrow) ggml_vec_index(); + if (idx == nullptr) { + return nullptr; + } + idx->dim = dim; + idx->bit_width = bit_width; + return idx; +} + +void ggml_vec_index_free(ggml_vec_index_t * idx) { + delete idx; +} + +// --------------------------------------------------------------------------- +// Mutation +// --------------------------------------------------------------------------- + +int ggml_vec_index_add( + ggml_vec_index_t * idx, + const float * vectors, + int n, + const uint64_t * ids) { + + if (idx == nullptr || vectors == nullptr || ids == nullptr) { + return GGML_VEC_INDEX_E_INVALID_ARG; + } + if (n < 0) { + return GGML_VEC_INDEX_E_INVALID_ARG; + } + if (n == 0) { + return GGML_VEC_INDEX_OK; + } + + // Atomic add: detect duplicates first (against existing AND in-batch), + // bail before mutating any state. + for (int i = 0; i < n; ++i) { + if (idx->id_to_slot.find(ids[i]) != idx->id_to_slot.end()) { + return GGML_VEC_INDEX_E_DUPLICATE; + } + for (int j = i + 1; j < n; ++j) { + if (ids[i] == ids[j]) { + return GGML_VEC_INDEX_E_DUPLICATE; + } + } + } + + const size_t base_slot = idx->slot_to_id.size(); + const size_t dim_sz = static_cast(idx->dim); + + try { + idx->data.resize((base_slot + static_cast(n)) * dim_sz); + idx->slot_to_id.resize(base_slot + static_cast(n)); + idx->id_to_slot.reserve(base_slot + static_cast(n)); + } catch (const std::bad_alloc &) { + idx->data.resize(base_slot * dim_sz); + idx->slot_to_id.resize(base_slot); + return GGML_VEC_INDEX_E_OOM; + } + + for (int i = 0; i < n; ++i) { + const size_t slot = base_slot + static_cast(i); + std::memcpy( + idx->data.data() + slot * dim_sz, + vectors + static_cast(i) * dim_sz, + dim_sz * sizeof(float)); + idx->slot_to_id[slot] = ids[i]; + idx->id_to_slot.emplace(ids[i], slot); + } + return GGML_VEC_INDEX_OK; +} + +int ggml_vec_index_remove(ggml_vec_index_t * idx, uint64_t id) { + if (idx == nullptr) { + return GGML_VEC_INDEX_E_INVALID_ARG; + } + auto it = idx->id_to_slot.find(id); + if (it == idx->id_to_slot.end()) { + return 0; + } + const size_t slot = it->second; + const size_t last = idx->slot_to_id.size() - 1; + const size_t dim_sz = static_cast(idx->dim); + + if (slot != last) { + // Move last vector into the freed slot and update its id mapping. + std::memcpy( + idx->data.data() + slot * dim_sz, + idx->data.data() + last * dim_sz, + dim_sz * sizeof(float)); + const uint64_t moved_id = idx->slot_to_id[last]; + idx->slot_to_id[slot] = moved_id; + idx->id_to_slot[moved_id] = slot; + } + + idx->slot_to_id.pop_back(); + idx->data.resize(last * dim_sz); + idx->id_to_slot.erase(it); + return 1; +} + +int ggml_vec_index_contains(const ggml_vec_index_t * idx, uint64_t id) { + if (idx == nullptr) { + return 0; + } + return idx->id_to_slot.count(id) != 0 ? 1 : 0; +} + +void ggml_vec_index_prepare(ggml_vec_index_t * /*idx*/) { + // POC no-op. Future: warm caches, materialize codebooks, etc. +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +namespace { + +// Scalar dot product of two `dim`-length f32 vectors. +inline float dot(const float * a, const float * b, int dim) { + float acc = 0.0f; + for (int i = 0; i < dim; ++i) { + acc += a[i] * b[i]; + } + return acc; +} + +// Run a single query against all slots, write top-k into out_scores/out_ids. +// If the index holds fewer than k entries, pad with sentinels. +void search_one( + const ggml_vec_index_t & idx, + const float * query, + int k, + float * out_scores, + uint64_t * out_ids) { + + const int dim = idx.dim; + const size_t n_slots = idx.slot_to_id.size(); + + std::priority_queue, MinHeapCmp> heap; + + for (size_t slot = 0; slot < n_slots; ++slot) { + const float s = dot( + query, idx.data.data() + slot * static_cast(dim), dim); + if (heap.size() < static_cast(k)) { + heap.push({ s, idx.slot_to_id[slot] }); + } else if (s > heap.top().score) { + heap.pop(); + heap.push({ s, idx.slot_to_id[slot] }); + } + } + + // Drain the heap into a temporary descending list. + std::vector drained; + drained.reserve(heap.size()); + while (!heap.empty()) { + drained.push_back(heap.top()); + heap.pop(); + } + std::reverse(drained.begin(), drained.end()); // now descending by score + + for (int i = 0; i < k; ++i) { + if (static_cast(i) < drained.size()) { + out_scores[i] = drained[i].score; + out_ids[i] = drained[i].id; + } else { + out_scores[i] = -FLT_MAX; + out_ids[i] = UINT64_MAX; + } + } +} + +} // namespace + +int ggml_vec_index_search( + const ggml_vec_index_t * idx, + const float * queries, + int n_q, + int k, + float * out_scores, + uint64_t * out_ids) { + + if (idx == nullptr || queries == nullptr || + out_scores == nullptr || out_ids == nullptr) { + return GGML_VEC_INDEX_E_INVALID_ARG; + } + if (n_q < 0 || k <= 0) { + return GGML_VEC_INDEX_E_INVALID_ARG; + } + if (n_q == 0) { + return GGML_VEC_INDEX_OK; + } + + const int dim = idx->dim; + for (int q = 0; q < n_q; ++q) { + search_one( + *idx, + queries + static_cast(q) * static_cast(dim), + k, + out_scores + static_cast(q) * static_cast(k), + out_ids + static_cast(q) * static_cast(k)); + } + return GGML_VEC_INDEX_OK; +} + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +int ggml_vec_index_write(ggml_vec_index_t * idx, const char * path) { + if (idx == nullptr || path == nullptr) { + return GGML_VEC_INDEX_E_INVALID_ARG; + } + + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f.is_open()) { + return GGML_VEC_INDEX_E_IO; + } + + // Header: 16 bytes. Layout matches the comment block in the header file. + uint8_t header[kTvimHeaderSize] = {}; + std::memcpy(header, kTvimMagic, 4); + header[4] = kTvimVersion; + header[5] = static_cast(idx->bit_width); + header[6] = 0; + header[7] = 0; + const uint32_t dim_le = static_cast(idx->dim); + const uint32_t n_le = static_cast(idx->slot_to_id.size()); + std::memcpy(header + 8, &dim_le, 4); + std::memcpy(header + 12, &n_le, 4); + + f.write(reinterpret_cast(header), sizeof(header)); + if (!f) { return GGML_VEC_INDEX_E_IO; } + + if (n_le > 0) { + f.write( + reinterpret_cast(idx->data.data()), + static_cast(idx->data.size() * sizeof(float))); + if (!f) { return GGML_VEC_INDEX_E_IO; } + + f.write( + reinterpret_cast(idx->slot_to_id.data()), + static_cast(idx->slot_to_id.size() * sizeof(uint64_t))); + if (!f) { return GGML_VEC_INDEX_E_IO; } + } + + f.flush(); + if (!f) { return GGML_VEC_INDEX_E_IO; } + return GGML_VEC_INDEX_OK; +} + +ggml_vec_index_t * ggml_vec_index_load(const char * path) { + if (path == nullptr) { + return nullptr; + } + std::ifstream f(path, std::ios::binary); + if (!f.is_open()) { + return nullptr; + } + + uint8_t header[kTvimHeaderSize] = {}; + f.read(reinterpret_cast(header), sizeof(header)); + if (!f || f.gcount() != static_cast(sizeof(header))) { + return nullptr; + } + if (std::memcmp(header, kTvimMagic, 4) != 0) { + return nullptr; + } + if (header[4] != kTvimVersion) { + return nullptr; + } + + const int bit_width = static_cast(header[5]); + uint32_t dim_le = 0; + uint32_t n_le = 0; + std::memcpy(&dim_le, header + 8, 4); + std::memcpy(&n_le, header + 12, 4); + if (dim_le == 0) { + return nullptr; + } + const int dim = static_cast(dim_le); + + auto * idx = ggml_vec_index_create(dim, bit_width); + if (idx == nullptr) { + return nullptr; + } + const size_t dim_sz = static_cast(dim); + const size_t n = static_cast(n_le); + + try { + idx->data.resize(n * dim_sz); + idx->slot_to_id.resize(n); + idx->id_to_slot.reserve(n); + } catch (const std::bad_alloc &) { + ggml_vec_index_free(idx); + return nullptr; + } + + if (n > 0) { + f.read( + reinterpret_cast(idx->data.data()), + static_cast(n * dim_sz * sizeof(float))); + if (!f) { + ggml_vec_index_free(idx); + return nullptr; + } + + f.read( + reinterpret_cast(idx->slot_to_id.data()), + static_cast(n * sizeof(uint64_t))); + if (!f) { + ggml_vec_index_free(idx); + return nullptr; + } + + for (size_t slot = 0; slot < n; ++slot) { + const uint64_t id = idx->slot_to_id[slot]; + auto [it, inserted] = idx->id_to_slot.emplace(id, slot); + if (!inserted) { + // Duplicate id in persisted file: corrupted. + ggml_vec_index_free(idx); + return nullptr; + } + } + } + + return idx; +} + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +int ggml_vec_index_len(const ggml_vec_index_t * idx) { + return idx ? static_cast(idx->slot_to_id.size()) : 0; +} + +int ggml_vec_index_dim(const ggml_vec_index_t * idx) { + return idx ? idx->dim : 0; +} + +int ggml_vec_index_bit_width(const ggml_vec_index_t * idx) { + return idx ? idx->bit_width : 0; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3a48f1aa83a3..82954c9fede7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -143,6 +143,10 @@ if (NOT WIN32) ) endif() +# POC vector-index smoke test (turbovec POC, ggml-vector-index.h). Standalone +# C-API exercise: no model required, no llama runtime touched. +llama_build_and_test(test-vector-index.cpp) + if (LLAMA_LLGUIDANCE) llama_build_and_test(test-grammar-llguidance.cpp ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-llama-bpe.gguf) endif () diff --git a/tests/test-vector-index.cpp b/tests/test-vector-index.cpp new file mode 100644 index 000000000000..305deceb8d21 --- /dev/null +++ b/tests/test-vector-index.cpp @@ -0,0 +1,160 @@ +// test-vector-index.cpp — standalone C-API smoke test for the POC vector +// index. Exercises lifecycle, add, search, remove, contains, write, load, +// search-after-load. No model, no llama; only the new ggml-vector-index +// public C API. + +#include "ggml-vector-index.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kDim = 4; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond);\ + std::exit(1); \ + } \ + } while (0) + +std::vector normalize(std::vector v) { + double sumsq = 0.0; + for (float x : v) sumsq += static_cast(x) * x; + const float n = static_cast(std::sqrt(sumsq)); + if (n > 0.0f) for (float & x : v) x /= n; + return v; +} + +} // namespace + +int main() { + auto * idx = ggml_vec_index_create(kDim, /*bit_width=*/32); + CHECK(idx != nullptr); + CHECK(ggml_vec_index_dim(idx) == kDim); + CHECK(ggml_vec_index_len(idx) == 0); + CHECK(ggml_vec_index_bit_width(idx) == 32); + + // Add 4 well-separated unit vectors. IDs are non-trivial uint64 to + // catch sign-extension or BigInt round-trip bugs at the JS boundary + // when this codepath is later exercised from Bare. + std::vector vecs; + std::vector ids = { + 42ULL, + (1ULL << 40) + 7ULL, + (1ULL << 62) + 11ULL, + UINT64_MAX - 13ULL, + }; + std::vector> seeds = { + normalize({1.0f, 0.0f, 0.0f, 0.0f}), + normalize({0.0f, 1.0f, 0.0f, 0.0f}), + normalize({0.0f, 0.0f, 1.0f, 0.0f}), + normalize({0.0f, 0.0f, 0.0f, 1.0f}), + }; + for (const auto & s : seeds) { + vecs.insert(vecs.end(), s.begin(), s.end()); + } + CHECK(ggml_vec_index_add( + idx, vecs.data(), static_cast(ids.size()), ids.data()) == 0); + CHECK(ggml_vec_index_len(idx) == 4); + CHECK(ggml_vec_index_contains(idx, ids[0]) == 1); + CHECK(ggml_vec_index_contains(idx, 999ULL) == 0); + + // Duplicate add must fail without mutating state. + { + const std::vector dup_ids = { ids[0] }; + std::vector dup_vec(seeds[0]); + CHECK(ggml_vec_index_add(idx, dup_vec.data(), 1, dup_ids.data()) + == GGML_VEC_INDEX_E_DUPLICATE); + CHECK(ggml_vec_index_len(idx) == 4); + } + + // Top-1 of querying with each unit vector should retrieve itself with + // score very close to 1.0 (full f32, no quantization noise). + { + std::array scores{}; + std::array out_ids{}; + for (size_t i = 0; i < seeds.size(); ++i) { + CHECK(ggml_vec_index_search( + idx, seeds[i].data(), 1, /*k=*/1, + scores.data(), out_ids.data()) == 0); + CHECK(out_ids[0] == ids[i]); + CHECK(std::fabs(scores[0] - 1.0f) < 1e-5f); + } + } + + // Top-k > len returns sentinel-padded tail. + { + std::array scores{}; + std::array out_ids{}; + CHECK(ggml_vec_index_search( + idx, seeds[0].data(), 1, /*k=*/8, + scores.data(), out_ids.data()) == 0); + CHECK(out_ids[0] == ids[0]); + // Tail entries (positions 4..7) padded with UINT64_MAX. + for (int i = 4; i < 8; ++i) { + CHECK(out_ids[i] == UINT64_MAX); + } + } + + // Remove + search: the removed id must no longer surface. + { + CHECK(ggml_vec_index_remove(idx, ids[1]) == 1); + CHECK(ggml_vec_index_remove(idx, ids[1]) == 0); // already gone + CHECK(ggml_vec_index_len(idx) == 3); + CHECK(ggml_vec_index_contains(idx, ids[1]) == 0); + + std::array scores{}; + std::array out_ids{}; + CHECK(ggml_vec_index_search( + idx, seeds[1].data(), 1, /*k=*/3, + scores.data(), out_ids.data()) == 0); + for (int i = 0; i < 3; ++i) { + CHECK(out_ids[i] != ids[1]); + } + } + + // Persistence round-trip: write, free, load, re-query. + const auto tmp = std::filesystem::temp_directory_path() / + "ggml-vector-index-test.tvim"; + const std::string path = tmp.string(); + CHECK(ggml_vec_index_write(idx, path.c_str()) == 0); + + ggml_vec_index_free(idx); + + auto * loaded = ggml_vec_index_load(path.c_str()); + CHECK(loaded != nullptr); + CHECK(ggml_vec_index_dim(loaded) == kDim); + CHECK(ggml_vec_index_len(loaded) == 3); + CHECK(ggml_vec_index_bit_width(loaded) == 32); + CHECK(ggml_vec_index_contains(loaded, ids[0]) == 1); + CHECK(ggml_vec_index_contains(loaded, ids[1]) == 0); // stayed deleted + CHECK(ggml_vec_index_contains(loaded, ids[2]) == 1); + CHECK(ggml_vec_index_contains(loaded, ids[3]) == 1); + + // Top-1 self-match after reload. + { + std::array scores{}; + std::array out_ids{}; + CHECK(ggml_vec_index_search( + loaded, seeds[0].data(), 1, /*k=*/1, + scores.data(), out_ids.data()) == 0); + CHECK(out_ids[0] == ids[0]); + CHECK(std::fabs(scores[0] - 1.0f) < 1e-5f); + } + + ggml_vec_index_free(loaded); + std::filesystem::remove(path); + + std::printf("test-vector-index: OK\n"); + return 0; +}