From a8ad79c5a3d24768ea42ebeed2dfea07a1ec190b Mon Sep 17 00:00:00 2001 From: Nidhin Date: Fri, 10 Jul 2026 16:29:46 +0530 Subject: [PATCH 01/11] feat: add turbovec IdMapIndex binding - Add native vector index wrapper and Bare binding for IdMapIndex - Expose JS and TypeScript APIs for add/search/remove/persist flows - Add qvac-fabric overlay and integration coverage for the POC --- packages/embed-llamacpp/CMakeLists.txt | 41 ++ .../addon/src/addon/VectorIndexErrors.hpp | 42 ++ .../addon/src/js-interface/binding.cpp | 11 + .../src/js-interface/vector-index-binding.cpp | 517 ++++++++++++++++++ .../addon/src/model-interface/VectorIndex.cpp | 87 +++ .../addon/src/model-interface/VectorIndex.hpp | 73 +++ packages/embed-llamacpp/idMapIndex.js | 160 ++++++ packages/embed-llamacpp/index.d.ts | 54 ++ packages/embed-llamacpp/index.js | 8 + packages/embed-llamacpp/package.json | 5 + .../embed-llamacpp/scripts/rebuild-fabric.sh | 32 ++ .../scripts/sync-fabric-overlay.sh | 79 +++ .../test/integration/id-map-index.test.js | 173 ++++++ packages/embed-llamacpp/vcpkg.json | 3 + .../qvac-fabric/android-vulkan-version.cmake | 36 ++ .../vcpkg/ports/qvac-fabric/portfile.cmake | 229 ++++++++ .../vcpkg/ports/qvac-fabric/vcpkg.json | 33 ++ 17 files changed, 1583 insertions(+) create mode 100644 packages/embed-llamacpp/addon/src/addon/VectorIndexErrors.hpp create mode 100644 packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp create mode 100644 packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp create mode 100644 packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp create mode 100644 packages/embed-llamacpp/idMapIndex.js create mode 100755 packages/embed-llamacpp/scripts/rebuild-fabric.sh create mode 100755 packages/embed-llamacpp/scripts/sync-fabric-overlay.sh create mode 100644 packages/embed-llamacpp/test/integration/id-map-index.test.js create mode 100644 packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake create mode 100644 packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake create mode 100644 packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json diff --git a/packages/embed-llamacpp/CMakeLists.txt b/packages/embed-llamacpp/CMakeLists.txt index 39aa2b7d1f..00302ad417 100644 --- a/packages/embed-llamacpp/CMakeLists.txt +++ b/packages/embed-llamacpp/CMakeLists.txt @@ -17,6 +17,21 @@ find_package(cmake-vcpkg REQUIRED PATHS node_modules/cmake-vcpkg) set(VCPKG_OVERLAY_TRIPLETS "${CMAKE_CURRENT_SOURCE_DIR}/../../vcpkg-overlays/triplets;${VCPKG_OVERLAY_TRIPLETS}") +# Local overlay-port for fabric (turbovec POC). The overlay at +# `vcpkg/ports/qvac-fabric/` carries the POC fabric build because the public +# `tetherto/qvac-registry-vcpkg` qvac-fabric port has no 8828.x line yet and +# this manifest pins `>=8828.1.0`. By default the overlay fetches from +# `dev-nid/qvac-fabric-llm.cpp` (poc/turbovec-embed branch) at a pinned +# commit + SHA512. Drop this overlay once the registry publishes 8828.x. +# +# Local-iteration override: set `QVAC_FABRIC_LOCAL_PATH=/path/to/fabric` to +# build from a working tree instead. In that mode, run +# `./scripts/sync-fabric-overlay.sh` after each edit to bump the +# portfile-tracked source hash (vcpkg derives ABI from portfile contents, +# not from the source it copies in). `./scripts/rebuild-fabric.sh` chains +# sync + generate + build + install for the inner-loop case. +set(VCPKG_OVERLAY_PORTS "${CMAKE_CURRENT_SOURCE_DIR}/vcpkg/ports;${VCPKG_OVERLAY_PORTS}") + project(embed-llamacpp C CXX) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") @@ -68,6 +83,7 @@ target_sources( ${embed-llamacpp} PRIVATE ${PROJECT_SOURCE_DIR}/addon/src/js-interface/binding.cpp + ${PROJECT_SOURCE_DIR}/addon/src/js-interface/vector-index-binding.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/utils.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/AsyncWeightsLoader.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/BackendSelection.cpp @@ -75,6 +91,7 @@ target_sources( ${PROJECT_SOURCE_DIR}/addon/src/model-interface/LlamaLazyInitializeBackend.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/ModelMetadata.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/BertModel.cpp + ${PROJECT_SOURCE_DIR}/addon/src/model-interface/VectorIndex.cpp ) target_include_directories( ${embed-llamacpp} @@ -82,6 +99,30 @@ target_include_directories( ${QVAC_LIB_INFERENCE_ADDON_CPP_INCLUDE_DIRS} ${PROJECT_SOURCE_DIR}/addon/src ) +# Some fabric branches (HEAD-ish) only export the `llama` target without +# namespacing and without exporting the `llama-common` helper lib at all. +# The `temp-8828` branch exports the full `llama::*` namespace incl. +# `llama::common`. Add a shim only when the expected targets are missing. +if(TARGET llama AND NOT TARGET llama::llama) + add_library(llama::llama ALIAS llama) +endif() +if(NOT TARGET llama::common) + if(NOT TARGET llama-common) + find_library(LLAMA_COMMON_LIB + NAMES llama-common + REQUIRED + HINTS ${LLAMA_LIB_DIR} + NO_CMAKE_FIND_ROOT_PATH) + add_library(llama-common UNKNOWN IMPORTED) + set_target_properties(llama-common + PROPERTIES + IMPORTED_LOCATION "${LLAMA_COMMON_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${LLAMA_INCLUDE_DIR}" + INTERFACE_LINK_LIBRARIES "llama") + endif() + add_library(llama::common ALIAS llama-common) +endif() + target_link_libraries( ${embed-llamacpp} PRIVATE diff --git a/packages/embed-llamacpp/addon/src/addon/VectorIndexErrors.hpp b/packages/embed-llamacpp/addon/src/addon/VectorIndexErrors.hpp new file mode 100644 index 0000000000..5980ac89fb --- /dev/null +++ b/packages/embed-llamacpp/addon/src/addon/VectorIndexErrors.hpp @@ -0,0 +1,42 @@ +#pragma once +// +// Maps fabric vector-index C error codes to JS-throwable messages. Mirrors +// the layout of `BertErrors.hpp` but for the ANN index path. Kept in its own +// header so the binding can include it without dragging in any BertModel / +// LlamaLazyInitializeBackend symbols (lifecycle isolation requirement of +// the POC). + +#include + +#include + +namespace qvac_lib_infer_llamacpp_embed::vector_index_errors { + +constexpr const char* ADDON_ID = "IdMapIndex"; + +inline std::string toString(int code) { + switch (code) { + case GGML_VEC_INDEX_OK: + return "OK"; + case GGML_VEC_INDEX_E_INVALID_DIM: + return "InvalidDim"; + case GGML_VEC_INDEX_E_INVALID_ARG: + return "InvalidArgument"; + case GGML_VEC_INDEX_E_DUPLICATE: + return "DuplicateId"; + case GGML_VEC_INDEX_E_IO: + return "IOError"; + case GGML_VEC_INDEX_E_BAD_MAGIC: + return "BadMagic"; + case GGML_VEC_INDEX_E_BAD_VERSION: + return "BadVersion"; + case GGML_VEC_INDEX_E_OOM: + return "OutOfMemory"; + case GGML_VEC_INDEX_E_INTERNAL: + return "InternalError"; + default: + return "UnknownError"; + } +} + +} // namespace qvac_lib_infer_llamacpp_embed::vector_index_errors diff --git a/packages/embed-llamacpp/addon/src/js-interface/binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/binding.cpp index d8ebeeb0d0..2c50ff9996 100644 --- a/packages/embed-llamacpp/addon/src/js-interface/binding.cpp +++ b/packages/embed-llamacpp/addon/src/js-interface/binding.cpp @@ -2,6 +2,15 @@ #include "../addon/AddonJs.hpp" +// Forward declaration for the IdMapIndex (vector-index) binding registrar. +// The implementation lives in `vector-index-binding.cpp` and is deliberately +// kept in its own TU so it has no symbol dependency on BertModel / +// LlamaLazyInitializeBackend — required for the POC's lifecycle-isolation +// invariant (constructing IdMapIndex must not boot fabric's LLM backend). +namespace qvac_lib_inference_addon_embed::vector_index { +void registerBindings(js_env_t* env, js_value_t* exports); +} + js_value_t* qvacLibInferLlamacppEmbedExports(js_env_t* env, js_value_t* exports) { @@ -30,6 +39,8 @@ qvacLibInferLlamacppEmbedExports(js_env_t* env, js_value_t* exports) { #undef V // NOLINTEND(cppcoreguidelines-macro-usage) + qvac_lib_inference_addon_embed::vector_index::registerBindings(env, exports); + return exports; } diff --git a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp new file mode 100644 index 0000000000..7fc47f04c9 --- /dev/null +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -0,0 +1,517 @@ +// vector-index-binding.cpp +// +// N-API surface for the `IdMapIndex` JS class. Registers a small set of free +// functions on the embed-llamacpp addon's exports; the JS wrapper in +// `idMapIndex.js` ties them into a class shape. +// +// Lifecycle isolation: this binding deliberately depends ONLY on the +// VectorIndex C++ wrapper (which in turn depends only on fabric's +// ggml-vector-index C API). It never references BertModel, +// LlamaLazyInitializeBackend, or any other BERT-runtime symbol, so simply +// importing the addon does not boot fabric's LLM backend. The same .bare +// binary carries both class surfaces; the JS side decides which to construct. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "../addon/VectorIndexErrors.hpp" +#include "../model-interface/VectorIndex.hpp" + +namespace { + +using qvac_lib_infer_llamacpp_embed::VectorIndex; +namespace verrors = qvac_lib_infer_llamacpp_embed::vector_index_errors; + +// Finalizer: invoked by the JS engine when the external handle is GC'd. +// Tears down the native C handle via VectorIndex's RAII dtor. +void +finalize_vector_index(js_env_t* /*env*/, void* data, void* /*hint*/) { + auto* idx = static_cast(data); + delete idx; +} + +// Wrap an already-constructed VectorIndex into a JS external. Takes +// ownership of `idx`; the JS engine will delete it via finalize on GC. +js_value_t* wrap(js_env_t* env, VectorIndex* idx) { + js_value_t* external = nullptr; + if (js_create_external(env, idx, finalize_vector_index, nullptr, &external) + != 0) { + delete idx; + js_throw_error(env, "InternalError", "failed to create external"); + return nullptr; + } + return external; +} + +// Get a borrowed pointer out of a JS external handle. Throws and returns +// null on failure. +VectorIndex* unwrap(js_env_t* env, js_value_t* handle) { + void* data = nullptr; + if (js_get_value_external(env, handle, &data) != 0 || data == nullptr) { + js_throw_error(env, "InvalidArgument", "expected IdMapIndex handle"); + return nullptr; + } + return static_cast(data); +} + +// Read a JS object property and parse as int32. On failure, throws and +// returns the provided default; caller should check pending exception via +// js_is_exception_pending or by returning nullptr from the function. +bool read_int_prop( + js_env_t* env, js_value_t* obj, const char* name, int32_t* out) { + js_value_t* val = nullptr; + if (js_get_named_property(env, obj, name, &val) != 0) { + return false; + } + return js_get_value_int32(env, val, out) == 0; +} + +void throw_status(js_env_t* env, int code) { + const std::string name = verrors::toString(code); + js_throw_error(env, name.c_str(), name.c_str()); +} + +bool read_utf8_string( + js_env_t* env, + js_value_t* value, + const char* type_error, + const char* read_error, + std::string* out) { + size_t len = 0; + if (js_get_value_string_utf8(env, value, nullptr, 0, &len) != 0) { + js_throw_type_error(env, "InvalidArgument", type_error); + return false; + } + if (len == std::numeric_limits::max()) { + js_throw_range_error(env, "InvalidArgument", "string is too large"); + return false; + } + + std::vector buffer(len + 1); + size_t copied = 0; + if (js_get_value_string_utf8( + env, value, buffer.data(), buffer.size(), &copied) != 0) { + js_throw_error(env, "InternalError", read_error); + return false; + } + + out->assign(reinterpret_cast(buffer.data()), copied); + return true; +} + +// --------------------------------------------------------------------------- +// Bindings +// --------------------------------------------------------------------------- + +// idx_create({ dim, bitWidth }) -> external handle +js_value_t* idx_create(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 1) { + js_throw_type_error(env, "InvalidArgument", "expected { dim, bitWidth }"); + return nullptr; + } + int32_t dim = 0; + int32_t bit_width = 32; + if (!read_int_prop(env, argv[0], "dim", &dim)) { + js_throw_type_error(env, "InvalidArgument", "missing or invalid `dim`"); + return nullptr; + } + // bitWidth optional; default to 32 if missing. + (void) read_int_prop(env, argv[0], "bitWidth", &bit_width); + + VectorIndex* idx = nullptr; + try { + idx = new VectorIndex(dim, bit_width); + } catch (const std::invalid_argument& e) { + js_throw_error(env, "InvalidArgument", e.what()); + return nullptr; + } catch (const std::bad_alloc&) { + js_throw_error(env, "OutOfMemory", "allocation failure"); + return nullptr; + } + return wrap(env, idx); +} + +// idx_load(path) -> external handle (throws on file errors). +js_value_t* idx_load(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 1) { + js_throw_type_error(env, "InvalidArgument", "expected path string"); + return nullptr; + } + std::string path; + if (!read_utf8_string( + env, argv[0], + "path must be a string", + "failed to read path string", + &path)) { + return nullptr; + } + + VectorIndex loaded = VectorIndex::load(path); + if (!loaded.valid()) { + js_throw_error(env, "IOError", "ggml_vec_index_load returned null"); + return nullptr; + } + // Move the wrapper onto the heap so we can hand JS an owning external. + auto* heap = new VectorIndex(std::move(loaded)); + return wrap(env, heap); +} + +// idx_add(handle, Float32Array vectors, BigUint64Array ids) -> undefined +js_value_t* idx_add(js_env_t* env, js_callback_info_t* info) { + size_t argc = 3; + js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 3) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, vectors:Float32Array, ids:BigUint64Array)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + js_typedarray_type_t vtype{}; + void* vdata = nullptr; + size_t vlen = 0; + if (js_get_typedarray_info( + env, argv[1], &vtype, &vdata, &vlen, nullptr, nullptr) != 0 + || vtype != js_float32array) { + js_throw_type_error(env, "InvalidArgument", + "vectors must be a Float32Array"); + return nullptr; + } + + js_typedarray_type_t itype{}; + void* idata = nullptr; + size_t ilen = 0; + if (js_get_typedarray_info( + env, argv[2], &itype, &idata, &ilen, nullptr, nullptr) != 0 + || itype != js_biguint64array) { + js_throw_type_error(env, "InvalidArgument", + "ids must be a BigUint64Array"); + return nullptr; + } + + const int dim = idx->dim(); + if (dim <= 0) { + js_throw_error(env, "InternalError", "index has invalid dim"); + return nullptr; + } + const size_t dim_size = static_cast(dim); + if (ilen > std::numeric_limits::max() / dim_size + || vlen != ilen * dim_size) { + js_throw_range_error(env, "InvalidArgument", + "vectors.length must equal ids.length * dim"); + return nullptr; + } + if (ilen > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many vectors in batch"); + return nullptr; + } + + const int rc = idx->add( + static_cast(vdata), + static_cast(ilen), + static_cast(idata)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// idx_search(handle, Float32Array queries, int k) +// -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } +js_value_t* idx_search(js_env_t* env, js_callback_info_t* info) { + size_t argc = 3; + js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 3) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, queries:Float32Array, k:number)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + js_typedarray_type_t qtype{}; + void* qdata = nullptr; + size_t qlen = 0; + if (js_get_typedarray_info( + env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 + || qtype != js_float32array) { + js_throw_type_error(env, "InvalidArgument", + "queries must be a Float32Array"); + return nullptr; + } + + int32_t k = 0; + if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { + js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + return nullptr; + } + + const int dim = idx->dim(); + if (dim <= 0 || qlen % static_cast(dim) != 0) { + js_throw_range_error(env, "InvalidArgument", + "queries.length must be a multiple of dim"); + return nullptr; + } + const size_t m = qlen / static_cast(dim); + if (m > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many queries"); + return nullptr; + } + const size_t k_size = static_cast(k); + const size_t max_size = std::numeric_limits::max(); + if (m != 0 && k_size > max_size / m) { + js_throw_range_error(env, "InvalidArgument", "search result is too large"); + return nullptr; + } + + // Allocate output ArrayBuffers; we'll hand them to JS via typed-array + // views. + const size_t total = m * k_size; + if (total > max_size / sizeof(uint64_t)) { + js_throw_range_error(env, "InvalidArgument", "search result is too large"); + return nullptr; + } + const size_t scores_b = total * sizeof(float); + const size_t ids_b = total * sizeof(uint64_t); + + void* scores_data = nullptr; + js_value_t* scores_ab = nullptr; + if (js_create_arraybuffer(env, scores_b, &scores_data, &scores_ab) != 0) { + js_throw_error(env, "OutOfMemory", "scores arraybuffer"); + return nullptr; + } + void* ids_data = nullptr; + js_value_t* ids_ab = nullptr; + if (js_create_arraybuffer(env, ids_b, &ids_data, &ids_ab) != 0) { + js_throw_error(env, "OutOfMemory", "ids arraybuffer"); + return nullptr; + } + + const int rc = idx->search( + static_cast(qdata), + static_cast(m), + k, + static_cast(scores_data), + static_cast(ids_data)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + js_value_t* scores_ta = nullptr; + if (js_create_typedarray( + env, js_float32array, total, scores_ab, 0, &scores_ta) != 0) { + js_throw_error(env, "InternalError", "create scores typedarray"); + return nullptr; + } + js_value_t* ids_ta = nullptr; + if (js_create_typedarray( + env, js_biguint64array, total, ids_ab, 0, &ids_ta) != 0) { + js_throw_error(env, "InternalError", "create ids typedarray"); + return nullptr; + } + + js_value_t* result = nullptr; + if (js_create_object(env, &result) != 0) { + js_throw_error(env, "InternalError", "create result object"); + return nullptr; + } + if (js_set_named_property(env, result, "scores", scores_ta) != 0 + || js_set_named_property(env, result, "ids", ids_ta) != 0) { + js_throw_error(env, "InternalError", "set result fields"); + return nullptr; + } + js_value_t* m_val = nullptr; + js_value_t* k_val = nullptr; + js_create_uint32(env, static_cast(m), &m_val); + js_create_int32(env, k, &k_val); + js_set_named_property(env, result, "m", m_val); + js_set_named_property(env, result, "k", k_val); + return result; +} + +// idx_remove(handle, id:bigint) -> boolean +js_value_t* idx_remove(js_env_t* env, js_callback_info_t* info) { + size_t argc = 2; + js_value_t* argv[2] = { nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 2) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, id:bigint)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + uint64_t id = 0; + bool lossless = false; + if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 + || !lossless) { + js_throw_type_error(env, "InvalidArgument", + "id must be an unsigned BigInt fitting in 64 bits"); + return nullptr; + } + + const int rc = idx->remove(id); + if (rc < 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* result = nullptr; + js_get_boolean(env, rc == 1, &result); + return result; +} + +// idx_contains(handle, id:bigint) -> boolean +js_value_t* idx_contains(js_env_t* env, js_callback_info_t* info) { + size_t argc = 2; + js_value_t* argv[2] = { nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 2) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, id:bigint)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + uint64_t id = 0; + bool lossless = false; + if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 + || !lossless) { + js_throw_type_error(env, "InvalidArgument", + "id must be an unsigned BigInt fitting in 64 bits"); + return nullptr; + } + js_value_t* result = nullptr; + js_get_boolean(env, idx->contains(id), &result); + return result; +} + +// idx_prepare(handle) -> undefined (no-op in POC). +js_value_t* idx_prepare(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + idx->prepare(); + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// idx_write(handle, path) -> undefined; throws on IO error. +js_value_t* idx_write(js_env_t* env, js_callback_info_t* info) { + size_t argc = 2; + js_value_t* argv[2] = { nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 2) { + js_throw_type_error(env, "InvalidArgument", "expected (handle, path)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + std::string path; + if (!read_utf8_string( + env, argv[1], + "path must be a string", + "failed to read path", + &path)) { + return nullptr; + } + + const int rc = idx->write(path); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// Generic int32 getter for len/dim/bitWidth. +template +js_value_t* idx_int_getter(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + js_value_t* result = nullptr; + js_create_int32(env, (idx->*Fn)(), &result); + return result; +} + +} // namespace + +namespace qvac_lib_inference_addon_embed::vector_index { + +void registerBindings(js_env_t* env, js_value_t* exports) { +// NOLINTBEGIN(cppcoreguidelines-macro-usage) +#define V(name, fn) \ + do { \ + js_value_t* val = nullptr; \ + if (js_create_function(env, name, -1, fn, nullptr, &val) != 0) { \ + return; \ + } \ + if (js_set_named_property(env, exports, name, val) != 0) { \ + return; \ + } \ + } while (0) + + V("idx_create", idx_create); + V("idx_load", idx_load); + V("idx_add", idx_add); + V("idx_search", idx_search); + V("idx_remove", idx_remove); + V("idx_contains", idx_contains); + V("idx_prepare", idx_prepare); + V("idx_write", idx_write); + V("idx_len", (idx_int_getter<&VectorIndex::len>)); + V("idx_dim", (idx_int_getter<&VectorIndex::dim>)); + V("idx_bit_width", (idx_int_getter<&VectorIndex::bitWidth>)); +#undef V +// NOLINTEND(cppcoreguidelines-macro-usage) +} + +} // namespace qvac_lib_inference_addon_embed::vector_index diff --git a/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp new file mode 100644 index 0000000000..bfd829b571 --- /dev/null +++ b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp @@ -0,0 +1,87 @@ +#include "VectorIndex.hpp" + +#include +#include + +namespace qvac_lib_infer_llamacpp_embed { + +VectorIndex::VectorIndex(int dim, int bitWidth) + : handle_(ggml_vec_index_create(dim, bitWidth)) { + if (handle_ == nullptr) { + throw std::invalid_argument( + "ggml_vec_index_create rejected dim/bitWidth"); + } +} + +VectorIndex::VectorIndex(ggml_vec_index_t* handle) noexcept : handle_(handle) {} + +VectorIndex::~VectorIndex() { + if (handle_ != nullptr) { + ggml_vec_index_free(handle_); + handle_ = nullptr; + } +} + +VectorIndex::VectorIndex(VectorIndex&& other) noexcept + : handle_(other.handle_) { + other.handle_ = nullptr; +} + +VectorIndex& VectorIndex::operator=(VectorIndex&& other) noexcept { + if (this != &other) { + if (handle_ != nullptr) { + ggml_vec_index_free(handle_); + } + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; +} + +int VectorIndex::add( + const float* vectors, int n, const uint64_t* ids) noexcept { + return ggml_vec_index_add(handle_, vectors, n, ids); +} + +int VectorIndex::remove(uint64_t id) noexcept { + return ggml_vec_index_remove(handle_, id); +} + +bool VectorIndex::contains(uint64_t id) const noexcept { + return ggml_vec_index_contains(handle_, id) != 0; +} + +void VectorIndex::prepare() noexcept { ggml_vec_index_prepare(handle_); } + +int VectorIndex::search( + const float* queries, + int n_q, + int k, + float* outScores, + uint64_t* outIds) const noexcept { + return ggml_vec_index_search( + handle_, queries, n_q, k, outScores, outIds); +} + +int VectorIndex::write(const std::string& path) noexcept { + return ggml_vec_index_write(handle_, path.c_str()); +} + +VectorIndex VectorIndex::load(const std::string& path) noexcept { + ggml_vec_index_t* raw = ggml_vec_index_load(path.c_str()); + return VectorIndex(raw); +} + +int VectorIndex::len() const noexcept { + return ggml_vec_index_len(handle_); +} + +int VectorIndex::dim() const noexcept { + return ggml_vec_index_dim(handle_); +} + +int VectorIndex::bitWidth() const noexcept { + return ggml_vec_index_bit_width(handle_); +} + +} // namespace qvac_lib_infer_llamacpp_embed diff --git a/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp new file mode 100644 index 0000000000..bd6fbc48fe --- /dev/null +++ b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp @@ -0,0 +1,73 @@ +#pragma once +// +// RAII C++ wrapper around fabric's `ggml_vec_index_t*` C handle. Provides +// typed methods + status-aware accessors. Lifecycle isolated from +// LlamaLazyInitializeBackend / BertModel by construction: this header +// only depends on the ggml-base vector-index C API. + +#include + +#include +#include + +namespace qvac_lib_infer_llamacpp_embed { + +class VectorIndex { +public: + // Construct a fresh empty index. Throws std::invalid_argument on bad + // dims / bit_width. + VectorIndex(int dim, int bitWidth); + + // Adopt an already-opened native handle (used by static load). + explicit VectorIndex(ggml_vec_index_t* handle) noexcept; + + ~VectorIndex(); + + VectorIndex(const VectorIndex&) = delete; + VectorIndex& operator=(const VectorIndex&) = delete; + VectorIndex(VectorIndex&& other) noexcept; + VectorIndex& operator=(VectorIndex&& other) noexcept; + + // Returns 0 on success, ggml_vec_index_error on failure (e.g. duplicate). + int add(const float* vectors, int n, const uint64_t* ids) noexcept; + + // Returns 1 / 0 (removed / not present), negative on error. + int remove(uint64_t id) noexcept; + + [[nodiscard]] bool contains(uint64_t id) const noexcept; + + void prepare() noexcept; + + // Top-k search. Caller owns out arrays of size n_q * k. + int search( + const float* queries, + int n_q, + int k, + float* outScores, + uint64_t* outIds) const noexcept; + + // Persists to disk. Returns 0 on success. + int write(const std::string& path) noexcept; + + // Reads from disk. On failure returns a wrapper whose `valid()` is false; + // callers must check before using the instance. + static VectorIndex load(const std::string& path) noexcept; + + // Stats. + [[nodiscard]] int len() const noexcept; + [[nodiscard]] int dim() const noexcept; + [[nodiscard]] int bitWidth() const noexcept; + + // True if this instance owns a native handle (i.e. wasn't moved-from / + // wasn't a failed load). + [[nodiscard]] bool valid() const noexcept { return handle_ != nullptr; } + + // Raw handle accessor for the JS binding's finalizer. Caller must not + // free; ownership remains with this object. + [[nodiscard]] ggml_vec_index_t* raw() const noexcept { return handle_; } + +private: + ggml_vec_index_t* handle_; +}; + +} // namespace qvac_lib_infer_llamacpp_embed diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js new file mode 100644 index 0000000000..be9e54a569 --- /dev/null +++ b/packages/embed-llamacpp/idMapIndex.js @@ -0,0 +1,160 @@ +'use strict' + +// IdMapIndex: thin JS wrapper around the IdMapIndex N-API bindings exposed +// by the embed-llamacpp addon (vector-index-binding.cpp). The native side +// keeps the index in RAM as full f32 vectors for the POC; quantization, +// SIMD, and GPU kernels are future work behind the same JS shape. +// +// Lifecycle isolation: constructing IdMapIndex does NOT load any BERT +// model. It only resolves the native binding (loaded once per process) +// and calls idx_create, which is a tiny C-API call into ggml-base. Pair +// this with the assertion in the smoke test (no GGMLBert constructor +// runs while exercising the index). + +const binding = require('./binding') + +const HANDLE = Symbol('IdMapIndex.handle') + +function ensureHandle (self) { + if (self[HANDLE] == null) { + throw new Error('IdMapIndex has been disposed') + } + return self[HANDLE] +} + +/** + * In-memory TurboQuant-style ANN vector index. Indexes vectors of fixed + * dimensionality and stable external uint64 ids; supports top-k cosine / + * dot-product search. + * + * Scope: POC. Internals store full f32 vectors and do scalar dot products; + * quantization, SIMD, and GPU paths come later behind the same JS API. + */ +class IdMapIndex { + /** + * @param {object} opts + * @param {number} opts.dim - vector dimensionality (must be > 0) + * @param {number} [opts.bitWidth=4] - reserved for future quantization + */ + constructor ({ dim, bitWidth = 4 } = {}) { + if (!Number.isInteger(dim) || dim <= 0) { + throw new TypeError('IdMapIndex: dim must be a positive integer') + } + if (!Number.isInteger(bitWidth) || bitWidth <= 0 || bitWidth > 32) { + throw new TypeError('IdMapIndex: bitWidth must be an integer in [1, 32]') + } + this[HANDLE] = binding.idx_create({ dim, bitWidth }) + } + + /** + * Load a persisted index from a .tvim file. Returns a fresh instance. + * @param {string} path + * @returns {Promise} + */ + static async load (path) { + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('IdMapIndex.load: path must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load(path) + return instance + } + + /** + * Insert `n` vectors with their external ids. Throws on duplicate id or + * dim mismatch; partial mutation is impossible (atomic add). + * @param {Float32Array} vectors - length = n * dim + * @param {BigUint64Array} ids - length = n + */ + addWithIds (vectors, ids) { + if (!(vectors instanceof Float32Array)) { + throw new TypeError('addWithIds: vectors must be a Float32Array') + } + if (!(ids instanceof BigUint64Array)) { + throw new TypeError('addWithIds: ids must be a BigUint64Array') + } + if (ids.length === 0) return + if (vectors.length !== ids.length * this.dim) { + throw new RangeError( + `addWithIds: vectors.length (${vectors.length}) must equal ids.length (${ids.length}) * dim (${this.dim})` + ) + } + binding.idx_add(ensureHandle(this), vectors, ids) + } + + /** + * Top-k search across `m` queries packed contiguously in `queries`. + * Returns `{ scores, ids, m, k }` where scores/ids are typed arrays of + * length m*k (row-major). + * @param {Float32Array} queries - length = m * dim + * @param {number} k + * @returns {{ scores: Float32Array, ids: BigUint64Array, m: number, k: number }} + */ + search (queries, k) { + if (!(queries instanceof Float32Array)) { + throw new TypeError('search: queries must be a Float32Array') + } + if (!Number.isInteger(k) || k <= 0) { + throw new TypeError('search: k must be a positive integer') + } + return binding.idx_search(ensureHandle(this), queries, k) + } + + /** + * Remove an entry by external id. + * @param {bigint} id + * @returns {boolean} true if removed, false if not present + */ + remove (id) { + if (typeof id !== 'bigint') { + throw new TypeError('remove: id must be a bigint') + } + return binding.idx_remove(ensureHandle(this), id) + } + + /** + * @param {bigint} id + * @returns {boolean} + */ + contains (id) { + if (typeof id !== 'bigint') { + throw new TypeError('contains: id must be a bigint') + } + return binding.idx_contains(ensureHandle(this), id) + } + + /** No-op for the POC. Placeholder for future cache warming. */ + prepare () { + binding.idx_prepare(ensureHandle(this)) + } + + /** + * Persist the index to disk in the .tvim v1 format. + * @param {string} path + */ + write (path) { + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('write: path must be a non-empty string') + } + binding.idx_write(ensureHandle(this), path) + } + + get length () { return binding.idx_len(ensureHandle(this)) } + get dim () { return binding.idx_dim(ensureHandle(this)) } + get bitWidth () { return binding.idx_bit_width(ensureHandle(this)) } + + /** + * Mark the instance as unusable. The native handle is owned by a + * JS-side external whose finalizer runs at GC time; this method only + * drops the local reference so subsequent calls throw (`ensureHandle`). + * No memory is reclaimed synchronously — that requires a finalizer + * pass. Kept as a method for API stability; future versions may add + * a safe early-free path (wrapper object + sentinel) without changing + * the call sites. + */ + async dispose () { + this[HANDLE] = null + } +} + +module.exports = IdMapIndex diff --git a/packages/embed-llamacpp/index.d.ts b/packages/embed-llamacpp/index.d.ts index eac46afd8c..5b70102854 100644 --- a/packages/embed-llamacpp/index.d.ts +++ b/packages/embed-llamacpp/index.d.ts @@ -87,3 +87,57 @@ export class BertInterface implements Addon { /** Returns the first shard (matching `-NNNNN-of-MMMMM.gguf`) or the sole entry for single-file models. */ export function pickPrimaryGgufPath(files: string[]): string + +// --------------------------------------------------------------------------- +// IdMapIndex (turbovec POC) +// --------------------------------------------------------------------------- + +export interface IdMapIndexOptions { + /** Vector dimensionality (must be > 0). */ + dim: number + /** Reserved for future quantization; POC stores full f32 internally. */ + bitWidth?: number +} + +export interface IdMapIndexSearchResult { + /** Row-major scores: m * k. Higher = closer. */ + scores: Float32Array + /** Row-major external ids: m * k. `UINT64_MAX` padding when index is shorter than k. */ + ids: BigUint64Array + /** Number of query rows. */ + m: number + /** Effective k (mirrors the requested k). */ + k: number +} + +export class IdMapIndex { + constructor(opts: IdMapIndexOptions) + + /** Open a persisted .tvim file written by `write()`. */ + static load(path: string): Promise + + /** + * Insert `n` vectors with stable external ids. Throws on duplicate id or + * dim mismatch; mutation is atomic per call. + */ + addWithIds(vectors: Float32Array, ids: BigUint64Array): void + + /** Top-k search across `queries.length / dim` rows. */ + search(queries: Float32Array, k: number): IdMapIndexSearchResult + + /** Returns true if removed, false if not present. */ + remove(id: bigint): boolean + contains(id: bigint): boolean + + /** No-op for the POC. */ + prepare(): void + + /** Persist to disk (.tvim v1). */ + write(path: string): void + + readonly length: number + readonly dim: number + readonly bitWidth: number + + dispose(): Promise +} diff --git a/packages/embed-llamacpp/index.js b/packages/embed-llamacpp/index.js index cab57eeb8d..5f71647a42 100644 --- a/packages/embed-llamacpp/index.js +++ b/packages/embed-llamacpp/index.js @@ -216,3 +216,11 @@ class GGMLBert { module.exports = GGMLBert module.exports.pickPrimaryGgufPath = pickPrimaryGgufPath + +// IdMapIndex (turbovec POC) is also re-exported here as a named property +// for discoverability when consumers `require('@qvac/embed-llamacpp')`. +// Consumers that want the ANN index WITHOUT loading this file's GGMLBert / +// addon require chain should import it directly via the package's +// `./idMapIndex` sub-export — that path goes straight to `./binding` and +// never touches `./addon`, satisfying the POC's lifecycle-isolation invariant. +module.exports.IdMapIndex = require('./idMapIndex') diff --git a/packages/embed-llamacpp/package.json b/packages/embed-llamacpp/package.json index d7c4f15ab0..85fdd12e96 100644 --- a/packages/embed-llamacpp/package.json +++ b/packages/embed-llamacpp/package.json @@ -41,6 +41,7 @@ "files": [ "binding.js", "index.js", + "idMapIndex.js", "addon.js", "addonLogging.js", "addonLogging.d.ts", @@ -80,6 +81,10 @@ "types": "./index.d.ts", "default": "./index.js" }, + "./idMapIndex": { + "types": "./index.d.ts", + "default": "./idMapIndex.js" + }, "./addonLogging": { "types": "./addonLogging.d.ts", "default": "./addonLogging.js" diff --git a/packages/embed-llamacpp/scripts/rebuild-fabric.sh b/packages/embed-llamacpp/scripts/rebuild-fabric.sh new file mode 100755 index 0000000000..b7803f76b2 --- /dev/null +++ b/packages/embed-llamacpp/scripts/rebuild-fabric.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Inner-loop rebuild after editing fabric C++ in a LOCAL fabric checkout. +# +# Set QVAC_FABRIC_LOCAL_PATH (or QVAC_FABRIC_SRC_DIR) to point at a fabric +# working tree before running. The script: +# 1. bumps the overlay portfile's fabric-src-hash so vcpkg rebuilds the +# qvac-fabric port from your local source, +# 2. regenerates CMake (which re-runs vcpkg manifest install), +# 3. builds + installs the addon. +# +# If QVAC_FABRIC_LOCAL_PATH is NOT set, the overlay portfile uses its +# pinned github fetch — there's nothing for this script to invalidate, and +# `bare-make build` alone is enough for addon-side edits. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +package_root="$(cd "$here/.." && pwd)" +cd "$package_root" + +if [ -z "${QVAC_FABRIC_LOCAL_PATH:-}${QVAC_FABRIC_SRC_DIR:-}" ]; then + echo "rebuild-fabric: WARNING — neither QVAC_FABRIC_LOCAL_PATH nor" + echo "rebuild-fabric: QVAC_FABRIC_SRC_DIR is set. The overlay is in" + echo "rebuild-fabric: github-fetch mode and will not pick up local edits." + echo "rebuild-fabric: To iterate on local fabric source set, e.g.," + echo "rebuild-fabric: export QVAC_FABRIC_LOCAL_PATH=/path/to/qvac-fabric-llm.cpp" +fi + +bash "$here/sync-fabric-overlay.sh" +bare-make generate +bare-make build +bare-make install +echo "rebuild-fabric: done" diff --git a/packages/embed-llamacpp/scripts/sync-fabric-overlay.sh b/packages/embed-llamacpp/scripts/sync-fabric-overlay.sh new file mode 100755 index 0000000000..4db9369cbd --- /dev/null +++ b/packages/embed-llamacpp/scripts/sync-fabric-overlay.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Bumps the `# fabric-src-hash:` comment in the qvac-fabric overlay portfile +# to a fresh hash of fabric source contents. +# +# This is ONLY needed when iterating on a local fabric checkout via the +# `QVAC_FABRIC_LOCAL_PATH` env-var override (Phase 0 fast-edit loop). In the +# default github-fetch mode the overlay is pinned by commit SHA + SHA512 and +# vcpkg invalidates the cache when those change. +# +# Why this script exists: vcpkg derives its ABI hash from the portfile +# contents, not from the source the portfile copies in. Without bumping a +# portfile-tracked field, local working-tree edits to fabric won't trigger a +# rebuild. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +package_root="$(cd "$here/.." && pwd)" +portfile="$package_root/vcpkg/ports/qvac-fabric/portfile.cmake" + +if [ ! -f "$portfile" ]; then + echo "sync-fabric-overlay: portfile not found at $portfile" >&2 + exit 1 +fi + +# Accept either QVAC_FABRIC_LOCAL_PATH (the overlay's runtime env var) or +# QVAC_FABRIC_SRC_DIR (legacy alias). +if [ -n "${QVAC_FABRIC_LOCAL_PATH:-}" ]; then + fabric_dir="$QVAC_FABRIC_LOCAL_PATH" +elif [ -n "${QVAC_FABRIC_SRC_DIR:-}" ]; then + fabric_dir="$QVAC_FABRIC_SRC_DIR" +else + # Best-effort default: sibling of the qvac-public checkout. + fabric_dir="$(cd "$package_root/../../../qvac-fabric-llm.cpp" 2>/dev/null && pwd || true)" +fi + +if [ -z "$fabric_dir" ] || [ ! -d "$fabric_dir" ]; then + echo "sync-fabric-overlay: fabric checkout not found." >&2 + echo " Tried (in order):" >&2 + echo " QVAC_FABRIC_LOCAL_PATH=${QVAC_FABRIC_LOCAL_PATH:-}" >&2 + echo " QVAC_FABRIC_SRC_DIR=${QVAC_FABRIC_SRC_DIR:-}" >&2 + echo " default sibling: $package_root/../../../qvac-fabric-llm.cpp" >&2 + exit 1 +fi + +if [ ! -f "$fabric_dir/CMakeLists.txt" ]; then + echo "sync-fabric-overlay: $fabric_dir does not look like a fabric checkout" >&2 + exit 1 +fi + +# Hash all tracked source under fabric. `git ls-files` is preferred (fast, +# respects .gitignore); fallback to `find` for non-git trees. +hash=$( + cd "$fabric_dir" && { + git ls-files \ + ':!*.gguf' ':!*.bin' ':!*.png' ':!*.jpg' \ + 2>/dev/null \ + || find . -type f \ + -not -path '*/.git/*' \ + -not -path '*/build*/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/__pycache__/*' + } | LC_ALL=C sort | xargs -I{} shasum "{}" 2>/dev/null | shasum | awk '{print $1}' +) + +if [ -z "$hash" ] || [ "${#hash}" -lt 20 ]; then + echo "sync-fabric-overlay: failed to compute fabric source hash" >&2 + exit 1 +fi + +tmp="$portfile.tmp.$$" +awk -v h="$hash" ' + /^# fabric-src-hash: / { print "# fabric-src-hash: " h; next } + { print } +' "$portfile" > "$tmp" + +mv "$tmp" "$portfile" +echo "sync-fabric-overlay: fabric source dir = $fabric_dir" +echo "sync-fabric-overlay: fabric-src-hash -> $hash" +echo "sync-fabric-overlay: portfile updated -> $portfile" diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js new file mode 100644 index 0000000000..1a380b8cc0 --- /dev/null +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -0,0 +1,173 @@ +'use strict' +// +// Phase 2 acceptance: exercises every JS method on `IdMapIndex` end-to-end +// without loading any BERT model. Pair-bound with the C++ test under +// fabric's `tests/test-vector-index.cpp` (which exercises the same C API +// directly): if both pass, the JS↔C++ binding is correct in both +// directions. + +const test = require('brittle') +const fs = require('bare-fs') +const path = require('bare-path') +const os = require('bare-os') + +// Pull IdMapIndex via the package's sub-export path. The package exports +// `./idMapIndex` so consumers can opt into just the ANN-index class +// without dragging the GGMLBert require chain. Loading this module must +// NOT boot any BERT runtime. Resolved via a relative path here because +// the integration tests run inside the package itself (no self-link). +// +// To catch a future regression that wires GGMLBert init back into the +// IdMapIndex path, the first test below inspects `require.cache` and +// asserts the GGMLBert module file was never loaded. Bare keys +// `require.cache` by `file://` URL, so we convert the resolved paths. +const cacheKey = (p) => 'file://' + require.resolve(p) +const KEY_IDMAP = cacheKey('../../idMapIndex') +const KEY_BINDING = cacheKey('../../binding') +const KEY_INDEX = cacheKey('../../index.js') +const KEY_ADDON = cacheKey('../../addon.js') + +const IdMapIndex = require('../../idMapIndex') + +// DIM is chosen large enough that the N seed vectors below can each be a +// distinct unit basis vector — keeps the "self-match top-1" assertion +// well-defined. +const DIM = 16 +const N = 10 + +function unitVec (i) { + const v = new Float32Array(DIM) + v[i] = 1 + return v +} + +let tmpCounter = 0 +function tmpPath (name) { + const pid = os.pid ? os.pid() : 0 + tmpCounter += 1 + return path.join(os.tmpdir(), + `${name}-${pid}-${Date.now()}-${tmpCounter}.tvim`) +} + +test('IdMapIndex sub-export does not boot the BERT runtime', (t) => { + // 1. Construct path: zero-arg + tiny-arg ctors must succeed without + // loading any model or running addon-side BERT init. + const idx = new IdMapIndex({ dim: DIM, bitWidth: 4 }) + t.is(idx.dim, DIM, 'dim getter') + t.is(idx.bitWidth, 4, 'bitWidth getter') + t.is(idx.length, 0, 'starts empty') + idx.dispose() + + // 2. Module-cache invariant: requiring `./idMapIndex` must NOT have + // transitively loaded the GGMLBert entry (`./index.js`) or the + // BertInterface plumbing (`./addon.js`). If a future refactor wires + // BERT init into the IdMapIndex path, one of these files will be + // in the cache after the require above. + t.ok(require.cache[KEY_IDMAP], + 'sub-export module loaded (sanity)') + t.ok(require.cache[KEY_BINDING], + 'native binding loaded (sanity)') + t.absent(require.cache[KEY_INDEX], + 'GGMLBert entry (index.js) NOT loaded by ./idMapIndex') + t.absent(require.cache[KEY_ADDON], + 'BertInterface plumbing (addon.js) NOT loaded by ./idMapIndex') +}) + +test('IdMapIndex: add + search + remove + persistence round-trip', async (t) => { + const idx = new IdMapIndex({ dim: DIM, bitWidth: 4 }) + + const vectors = new Float32Array(N * DIM) + const ids = new BigUint64Array(N) + for (let i = 0; i < N; i++) { + vectors.set(unitVec(i), i * DIM) + // Mix non-trivial ids to flush out BigInt/uint64 round-trip bugs. + ids[i] = BigInt(i) + (1n << 40n) + (1n << 62n) + } + + idx.addWithIds(vectors, ids) + t.is(idx.length, N, 'all 10 vectors inserted') + t.ok(idx.contains(ids[3]), 'contains a known id') + t.absent(idx.contains(999n), 'absent id missing') + + // Top-1 of querying with the i-th unit vector must return that vector's id + // with a score very close to 1.0 (full f32, no quantization noise). + for (let i = 0; i < N; i++) { + const q = unitVec(i) + const out = idx.search(q, 1) + t.is(out.m, 1) + t.is(out.k, 1) + t.is(out.ids[0], ids[i], `query=${i} retrieves itself`) + t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5, `score≈1.0 for self-match`) + } + + // Top-k > length pads sentinels at the tail. + { + const out = idx.search(unitVec(0), N + 4) + t.is(out.ids.length, N + 4) + for (let i = N; i < N + 4; i++) { + t.is(out.ids[i], (1n << 64n) - 1n, `sentinel id at tail slot ${i}`) + } + } + + // Duplicate add must throw atomically (length unchanged). + { + const dupIds = new BigUint64Array([ids[0]]) + const dupVec = unitVec(0) + try { + idx.addWithIds(dupVec, dupIds) + t.fail('duplicate add should have thrown') + } catch (e) { + t.pass('duplicate add threw: ' + (e.message || e.code)) + } + t.is(idx.length, N, 'length unchanged after rejected dup add') + } + + // Remove + search: the removed id should not surface. + { + const removed = idx.remove(ids[2]) + t.ok(removed === true, 'remove returns true on first call') + t.absent(idx.remove(ids[2]), 'remove returns false on second call') + t.absent(idx.contains(ids[2]), 'no longer contained') + t.is(idx.length, N - 1) + const out = idx.search(unitVec(2), 3) + for (let i = 0; i < 3; i++) { + t.absent(out.ids[i] === ids[2], `removed id absent from search result ${i}`) + } + } + + // prepare() is a no-op but must be callable. + idx.prepare() + + // Persist, dispose, reload, re-search. + const file = tmpPath('id-map-index-roundtrip') + try { + idx.write(file) + await idx.dispose() + + const loaded = await IdMapIndex.load(file) + t.is(loaded.dim, DIM, 'dim restored') + t.is(loaded.bitWidth, 4, 'bitWidth restored') + t.is(loaded.length, N - 1, 'length restored (deletion persisted)') + t.ok(loaded.contains(ids[0]), 'kept id still there') + t.absent(loaded.contains(ids[2]), 'deleted id stayed deleted') + + const out = loaded.search(unitVec(0), 1) + t.is(out.ids[0], ids[0], 'self-match after reload') + t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5) + + await loaded.dispose() + } finally { + if (fs.existsSync(file)) fs.unlinkSync(file) + } +}) + +test('IdMapIndex: BigInt id range edge cases', (t) => { + // 2^63 + 7 catches sign-extension bugs at the typed-array boundary. + const idx = new IdMapIndex({ dim: 2 }) + const edge = (1n << 63n) + 7n + idx.addWithIds(new Float32Array([0.5, 0.5]), new BigUint64Array([edge])) + t.ok(idx.contains(edge), 'high-bit id round-trips') + const out = idx.search(new Float32Array([0.5, 0.5]), 1) + t.is(out.ids[0], edge, 'high-bit id surfaces from search') + idx.dispose() +}) diff --git a/packages/embed-llamacpp/vcpkg.json b/packages/embed-llamacpp/vcpkg.json index 61cb14595c..e1dd3fc8de 100644 --- a/packages/embed-llamacpp/vcpkg.json +++ b/packages/embed-llamacpp/vcpkg.json @@ -1,4 +1,7 @@ { + "$schema-comment": "vcpkg requires `name` + a versioning field whenever the manifest uses `version>=` constraints or overlay-ports. Both are present here because the qvac-fabric pin (>=8828.1.0) goes through an overlay (vcpkg/ports/qvac-fabric).", + "name": "embed-llamacpp", + "version-string": "0.19.1", "dependencies": [ { "name": "opencl", diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake new file mode 100644 index 0000000000..485ea51bb9 --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake @@ -0,0 +1,36 @@ +# Function to detect Vulkan version from NDK vulkan_core.h +function(detect_ndk_vulkan_version) + string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" host_system_name_lower) + + # CMAKE_HOST_SYSTEM_PROCESSOR is unavailable here. Use a glob pattern to complete the folder instead. + file(GLOB host_dirs LIST_DIRECTORIES true "$ENV{ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${host_system_name_lower}-*") + if(host_dirs) + list(GET host_dirs 0 host_dir) + get_filename_component(host_arch "${host_dir}" NAME) + set(vulkan_core_h "$ENV{ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${host_arch}/sysroot/usr/include/vulkan/vulkan_core.h") + else() + message(FATAL "Could not find NDK host directory for ${host_system_name_lower}") + endif() + + if(NOT vulkan_core_h) + message(FATAL "vulkan_core.h not found, using default version") + endif() + + file(READ "${vulkan_core_h}" header_content) + string(REGEX MATCH "VK_HEADER_VERSION ([0-9]+)" version_match "${header_content}") + if(version_match) + set(header_version_3 "${CMAKE_MATCH_1}") + else() + message(FATAL "Could not extract VK_HEADER_VERSION from vulkan_core.h, using default: ${vulkan_version}") + endif() + + # Extract major.minor version from VK_HEADER_VERSION_COMPLETE for download URL + string(REGEX MATCH "VK_HEADER_VERSION_COMPLETE VK_MAKE_API_VERSION\\(([0-9]+), ([0-9]+), ([0-9]+)" version_match "${header_content}") + if(version_match) + set(major "${CMAKE_MATCH_2}") + set(minor "${CMAKE_MATCH_3}") + set(vulkan_version "${major}.${minor}.${header_version_3}" PARENT_SCOPE) + else() + message(FATAL "Could not extract major.minor version from vulkan_core.h, using default: ${vulkan_version}") + endif() +endfunction() diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake new file mode 100644 index 0000000000..baf73cb6d5 --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake @@ -0,0 +1,229 @@ +# LOCAL OVERLAY for qvac-fabric (turbovec POC). +# +# Pinned by default to a commit on +# https://github.com/dev-nid/qvac-fabric-llm.cpp.git +# (branch: poc/turbovec-embed) +# which adds the new ggml-vector-index module on top of upstream +# fabric's `v8828.1.1` (which is the highest 8828.x release tag that +# satisfies embed-llamacpp's `qvac-fabric >= 8828.1.0` manifest pin). +# +# Why an overlay at all: the public `tetherto/qvac-registry-vcpkg` port +# for qvac-fabric tops out at v7248.2.2 and has no 8828.x line yet, so +# the manifest's >=8828.1.0 constraint cannot be satisfied by the +# registry. This overlay closes that gap and is the single source of +# fabric source for the POC build. Drop it once the registry publishes +# an 8828.x port. +# +# Local-edit iteration (Phase 0 fast loop) — set QVAC_FABRIC_LOCAL_PATH +# to the path of a fabric working tree. The overlay then copies that +# directory into the vcpkg buildtree instead of fetching from github. +# Because vcpkg's ABI hash is derived from the portfile contents (not +# the source it copies in), use `./scripts/sync-fabric-overlay.sh` to +# bump the `# fabric-src-hash:` comment line on each iteration so vcpkg +# rebuilds the port. + +# fabric-src-hash: 0000000000000000000000000000000000000000 + +set(FABRIC_GH_REPO "dev-nid/qvac-fabric-llm.cpp") +set(FABRIC_GH_REF "dd934a6d9964a718eaca6630c72dc33fe88a8892") # poc/turbovec-embed +set(FABRIC_GH_HEAD_REF "poc/turbovec-embed") +set(FABRIC_GH_SHA512 + "ad696aa0382c207ce5ca3840b9ae0b2fd7ce85b458d0a472275e2fee933bb578539244c716f07ed087b718be0de1057139837d0ec57a8ac6b0eeafabe942fae5") + +if(DEFINED ENV{QVAC_FABRIC_LOCAL_PATH}) + set(FABRIC_LOCAL_PATH "$ENV{QVAC_FABRIC_LOCAL_PATH}") + if(NOT EXISTS "${FABRIC_LOCAL_PATH}/CMakeLists.txt") + message(FATAL_ERROR + "qvac-fabric overlay: QVAC_FABRIC_LOCAL_PATH='${FABRIC_LOCAL_PATH}' " + "is not a fabric checkout (no CMakeLists.txt).") + endif() + message(STATUS + "qvac-fabric overlay: LOCAL mode — copying source from ${FABRIC_LOCAL_PATH}") + set(SOURCE_PATH "${CURRENT_BUILDTREES_DIR}/src/qvac-fabric-local") + file(REMOVE_RECURSE "${SOURCE_PATH}") + file(MAKE_DIRECTORY "${SOURCE_PATH}") + file(COPY "${FABRIC_LOCAL_PATH}/" + DESTINATION "${SOURCE_PATH}" + PATTERN ".git" EXCLUDE + PATTERN "build" EXCLUDE + PATTERN ".cache" EXCLUDE + PATTERN "node_modules" EXCLUDE + PATTERN "__pycache__" EXCLUDE + PATTERN ".venv" EXCLUDE) +else() + message(STATUS + "qvac-fabric overlay: GITHUB mode — fetching ${FABRIC_GH_REPO}@${FABRIC_GH_REF}") + vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO ${FABRIC_GH_REPO} + REF ${FABRIC_GH_REF} + SHA512 ${FABRIC_GH_SHA512} + HEAD_REF ${FABRIC_GH_HEAD_REF}) +endif() + +vcpkg_check_features( + OUT_FEATURE_OPTIONS FEATURE_OPTIONS + FEATURES + force-profiler FORCE_GGML_VK_PERF_LOGGER + llama BUILD_LLAMA +) + +if (VCPKG_TARGET_IS_ANDROID) + include(${CMAKE_CURRENT_LIST_DIR}/android-vulkan-version.cmake) + detect_ndk_vulkan_version() + message(STATUS "Using Vulkan C++ wrappers from version: ${vulkan_version}") + file(DOWNLOAD + "https://github.com/KhronosGroup/Vulkan-Headers/archive/refs/tags/v${vulkan_version}.tar.gz" + "${SOURCE_PATH}/vulkan-sdk-${vulkan_version}.tar.gz" + TLS_VERIFY ON + ) + file(ARCHIVE_EXTRACT + INPUT "${SOURCE_PATH}/vulkan-sdk-${vulkan_version}.tar.gz" + DESTINATION "${SOURCE_PATH}" + PATTERNS "*.hpp" + ) + file(RENAME + "${SOURCE_PATH}/Vulkan-Headers-${vulkan_version}" + "${SOURCE_PATH}/ggml/src/ggml-vulkan/vulkan_cpp_wrapper" + ) +endif() + +set(PLATFORM_OPTIONS) + +if (VCPKG_TARGET_IS_OSX OR VCPKG_TARGET_IS_IOS) + list(APPEND PLATFORM_OPTIONS -DGGML_METAL=ON) + if (VCPKG_TARGET_IS_IOS) + list(APPEND PLATFORM_OPTIONS -DGGML_BLAS=OFF -DGGML_ACCELERATE=OFF) + endif() +else() + list(APPEND PLATFORM_OPTIONS -DGGML_VULKAN=ON) +endif() + +if(VCPKG_TARGET_IS_ANDROID) + set(DL_BACKENDS ON) + list(APPEND PLATFORM_OPTIONS + -DGGML_BACKEND_DL=ON + -DGGML_CPU_ALL_VARIANTS=ON + -DGGML_CPU_REPACK=ON) +else() + set(DL_BACKENDS OFF) +endif() + +if (VCPKG_TARGET_IS_ANDROID) + list(APPEND PLATFORM_OPTIONS + -DGGML_VULKAN_DISABLE_COOPMAT=ON + -DGGML_VULKAN_DISABLE_COOPMAT2=ON + -DGGML_OPENCL=ON) +endif() + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + DISABLE_PARALLEL_CONFIGURE + OPTIONS + -DGGML_NATIVE=OFF + -DGGML_CCACHE=OFF + -DGGML_OPENMP=OFF + -DGGML_LLAMAFILE=OFF + -DLLAMA_MTMD=ON + -DLLAMA_CURL=OFF + -DLLAMA_BUILD_TESTS=OFF + -DLLAMA_BUILD_TOOLS=OFF + -DLLAMA_BUILD_EXAMPLES=OFF + -DLLAMA_BUILD_SERVER=OFF + -DLLAMA_ALL_WARNINGS=OFF + ${PLATFORM_OPTIONS} + ${FEATURE_OPTIONS} +) + +vcpkg_cmake_install() +# Different fabric branches install CMake configs at either +# `lib/cmake/` (HEAD-ish) or the upstream-standard `share/` +# (temp-8828 / v7248.x). Detect which is present per package so the +# overlay works against both layouts without portfile edits. +if(EXISTS "${CURRENT_PACKAGES_DIR}/lib/cmake/ggml") + vcpkg_cmake_config_fixup( + PACKAGE_NAME ggml + CONFIG_PATH "lib/cmake/ggml" + DO_NOT_DELETE_PARENT_CONFIG_PATH) +else() + vcpkg_cmake_config_fixup(PACKAGE_NAME ggml) +endif() + +if(BUILD_LLAMA) + if(EXISTS "${CURRENT_PACKAGES_DIR}/lib/cmake/llama") + vcpkg_cmake_config_fixup(PACKAGE_NAME llama CONFIG_PATH "lib/cmake/llama") + else() + vcpkg_cmake_config_fixup(PACKAGE_NAME llama) + endif() +endif() + +vcpkg_copy_pdbs() +vcpkg_fixup_pkgconfig() + +if(BUILD_LLAMA) + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/tools/${PORT}") + if(EXISTS "${CURRENT_PACKAGES_DIR}/bin/convert_hf_to_gguf.py") + file(RENAME + "${CURRENT_PACKAGES_DIR}/bin/convert_hf_to_gguf.py" + "${CURRENT_PACKAGES_DIR}/tools/${PORT}/convert-hf-to-gguf.py") + endif() + if(EXISTS "${SOURCE_PATH}/gguf-py") + file(INSTALL "${SOURCE_PATH}/gguf-py" + DESTINATION "${CURRENT_PACKAGES_DIR}/tools/${PORT}") + endif() + # Vulkan-only artifact; absent on macOS (Metal backend) and iOS. + if(EXISTS "${CURRENT_PACKAGES_DIR}/bin/vulkan_profiling_analyzer.py") + file(RENAME + "${CURRENT_PACKAGES_DIR}/bin/vulkan_profiling_analyzer.py" + "${CURRENT_PACKAGES_DIR}/tools/${PORT}/vulkan_profiling_analyzer.py") + endif() +endif() + +if (NOT VCPKG_BUILD_TYPE) + if(EXISTS "${CURRENT_PACKAGES_DIR}/debug/bin/convert_hf_to_gguf.py") + file(REMOVE "${CURRENT_PACKAGES_DIR}/debug/bin/convert_hf_to_gguf.py") + endif() +endif() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") + +# Local fabric HEAD's llama-config.cmake `set_and_check`s LLAMA_BIN_DIR +# even for static builds, so we keep an (empty) bin/ around. Upstream's +# tagged release didn't, hence the original removal block. Preserve the +# dirs so `find_package(llama)` succeeds. +file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/bin") +file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/debug/bin") + +# Fabric branches differ in where (and whether) they install the `common/` +# headers from the llama helper library. The embed-llamacpp addon code +# references them as both `` and ``. To make +# both forms resolve to the same physical file (otherwise duplicate +# struct definitions break the build), normalize the layout: +# - Prefer `include/llama/common/`. The addon's `-isystem +# include/llama` lets `` fall through to that path. +# - Remove a redundant `include/common/` if both exist (the two trees +# would otherwise be distinct files for the preprocessor and re-define +# every struct on second inclusion). +# - Synthesize `include/llama/common/` from source if neither is present. +if(BUILD_LLAMA AND EXISTS "${SOURCE_PATH}/common") + set(_qvac_common_have_llama + "${CURRENT_PACKAGES_DIR}/include/llama/common/common.h") + set(_qvac_common_have_root + "${CURRENT_PACKAGES_DIR}/include/common/common.h") + if(EXISTS "${_qvac_common_have_llama}") + if(EXISTS "${_qvac_common_have_root}") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/include/common") + endif() + elseif(NOT EXISTS "${_qvac_common_have_root}") + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/include/llama/common") + file(COPY "${SOURCE_PATH}/common/" + DESTINATION "${CURRENT_PACKAGES_DIR}/include/llama/common" + FILES_MATCHING + PATTERN "*.h" + PATTERN "*.hpp" + PATTERN "*.inc") + endif() +endif() + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json new file mode 100644 index 0000000000..4753692f1f --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json @@ -0,0 +1,33 @@ +{ + "name": "qvac-fabric", + "version": "8828.1.0", + "port-version": 9999, + "description": "LLM inference in C/C++ (LOCAL OVERLAY built from sibling qvac-fabric-llm.cpp source tree)", + "homepage": "https://github.com/tetherto/qvac-fabric-llm.cpp", + "license": "MIT", + "dependencies": [ + { + "name": "opencl", + "platform": "android" + }, + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + } + ], + "default-features": [ + "llama" + ], + "features": { + "force-profiler": { + "description": "Force vk performance logging in ggml" + }, + "llama": { + "description": "Build llama components." + } + } +} From 368cf801b7422ad7ee270f870d1b8e542b44f3b4 Mon Sep 17 00:00:00 2001 From: Nidhin Date: Mon, 13 Jul 2026 21:26:38 +0530 Subject: [PATCH 02/11] feat: productionize turbovec IdMapIndex - Point qvac-fabric overlay at the production turbovec branch - Update IdMapIndex API for q8/f32 storage and .tvim v2 persistence - Expand integration coverage for q8, f32, validation, and corrupt loads --- packages/embed-llamacpp/CMakeLists.txt | 28 +-- .../src/js-interface/vector-index-binding.cpp | 32 ++- packages/embed-llamacpp/idMapIndex.js | 63 ++--- packages/embed-llamacpp/index.d.ts | 12 +- .../test/integration/id-map-index.test.js | 220 ++++++++++++------ packages/embed-llamacpp/vcpkg.json | 2 +- .../vcpkg/ports/qvac-fabric/portfile.cmake | 24 +- .../vcpkg/ports/qvac-fabric/vcpkg.json | 4 +- 8 files changed, 239 insertions(+), 146 deletions(-) diff --git a/packages/embed-llamacpp/CMakeLists.txt b/packages/embed-llamacpp/CMakeLists.txt index 00302ad417..b55c21e0ab 100644 --- a/packages/embed-llamacpp/CMakeLists.txt +++ b/packages/embed-llamacpp/CMakeLists.txt @@ -17,12 +17,11 @@ find_package(cmake-vcpkg REQUIRED PATHS node_modules/cmake-vcpkg) set(VCPKG_OVERLAY_TRIPLETS "${CMAKE_CURRENT_SOURCE_DIR}/../../vcpkg-overlays/triplets;${VCPKG_OVERLAY_TRIPLETS}") -# Local overlay-port for fabric (turbovec POC). The overlay at -# `vcpkg/ports/qvac-fabric/` carries the POC fabric build because the public -# `tetherto/qvac-registry-vcpkg` qvac-fabric port has no 8828.x line yet and -# this manifest pins `>=8828.1.0`. By default the overlay fetches from -# `dev-nid/qvac-fabric-llm.cpp` (poc/turbovec-embed branch) at a pinned -# commit + SHA512. Drop this overlay once the registry publishes 8828.x. +# Local overlay-port for fabric (turbovec). The overlay at +# `vcpkg/ports/qvac-fabric/` carries the vector-index fabric build until the +# public `tetherto/qvac-registry-vcpkg` qvac-fabric port publishes the same +# turbovec changes. By default the overlay fetches from +# `dev-nid/qvac-fabric-llm.cpp` (turbovec branch) at a pinned commit + SHA512. # # Local-iteration override: set `QVAC_FABRIC_LOCAL_PATH=/path/to/fabric` to # build from a working tree instead. In that mode, run @@ -99,15 +98,18 @@ target_include_directories( ${QVAC_LIB_INFERENCE_ADDON_CPP_INCLUDE_DIRS} ${PROJECT_SOURCE_DIR}/addon/src ) -# Some fabric branches (HEAD-ish) only export the `llama` target without -# namespacing and without exporting the `llama-common` helper lib at all. -# The `temp-8828` branch exports the full `llama::*` namespace incl. -# `llama::common`. Add a shim only when the expected targets are missing. +# Fabric branches differ in how they export llama targets. Normalize the +# targets this addon links against, preferring exported namespace targets and +# falling back only for older ports. if(TARGET llama AND NOT TARGET llama::llama) add_library(llama::llama ALIAS llama) endif() if(NOT TARGET llama::common) - if(NOT TARGET llama-common) + if(TARGET llama::llama-common) + add_library(llama::common ALIAS llama::llama-common) + elseif(TARGET llama-common) + add_library(llama::common ALIAS llama-common) + else() find_library(LLAMA_COMMON_LIB NAMES llama-common REQUIRED @@ -118,9 +120,9 @@ if(NOT TARGET llama::common) PROPERTIES IMPORTED_LOCATION "${LLAMA_COMMON_LIB}" INTERFACE_INCLUDE_DIRECTORIES "${LLAMA_INCLUDE_DIR}" - INTERFACE_LINK_LIBRARIES "llama") + INTERFACE_LINK_LIBRARIES "llama::llama") + add_library(llama::common ALIAS llama-common) endif() - add_library(llama::common ALIAS llama-common) endif() target_link_libraries( diff --git a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp index 7fc47f04c9..f9a46c33db 100644 --- a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -94,15 +94,20 @@ bool read_utf8_string( return false; } - std::vector buffer(len + 1); - size_t copied = 0; - if (js_get_value_string_utf8( - env, value, buffer.data(), buffer.size(), &copied) != 0) { - js_throw_error(env, "InternalError", read_error); + try { + std::vector buffer(len + 1); + size_t copied = 0; + if (js_get_value_string_utf8( + env, value, buffer.data(), buffer.size(), &copied) != 0) { + js_throw_error(env, "InternalError", read_error); + return false; + } + + out->assign(reinterpret_cast(buffer.data()), copied); + } catch (const std::bad_alloc&) { + js_throw_error(env, "OutOfMemory", "allocation failure"); return false; } - - out->assign(reinterpret_cast(buffer.data()), copied); return true; } @@ -168,9 +173,14 @@ js_value_t* idx_load(js_env_t* env, js_callback_info_t* info) { js_throw_error(env, "IOError", "ggml_vec_index_load returned null"); return nullptr; } - // Move the wrapper onto the heap so we can hand JS an owning external. - auto* heap = new VectorIndex(std::move(loaded)); - return wrap(env, heap); + try { + // Move the wrapper onto the heap so we can hand JS an owning external. + auto* heap = new VectorIndex(std::move(loaded)); + return wrap(env, heap); + } catch (const std::bad_alloc&) { + js_throw_error(env, "OutOfMemory", "allocation failure"); + return nullptr; + } } // idx_add(handle, Float32Array vectors, BigUint64Array ids) -> undefined @@ -419,7 +429,7 @@ js_value_t* idx_contains(js_env_t* env, js_callback_info_t* info) { return result; } -// idx_prepare(handle) -> undefined (no-op in POC). +// idx_prepare(handle) -> undefined (currently a no-op). js_value_t* idx_prepare(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; js_value_t* argv[1] = { nullptr }; diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js index be9e54a569..96b0f85267 100644 --- a/packages/embed-llamacpp/idMapIndex.js +++ b/packages/embed-llamacpp/idMapIndex.js @@ -2,8 +2,8 @@ // IdMapIndex: thin JS wrapper around the IdMapIndex N-API bindings exposed // by the embed-llamacpp addon (vector-index-binding.cpp). The native side -// keeps the index in RAM as full f32 vectors for the POC; quantization, -// SIMD, and GPU kernels are future work behind the same JS shape. +// supports full f32 storage (`bitWidth: 32`) and production q8 storage +// (`bitWidth: 8`) with CPU search against the selected representation. // // Lifecycle isolation: constructing IdMapIndex does NOT load any BERT // model. It only resolves the native binding (loaded once per process) @@ -15,33 +15,31 @@ const binding = require('./binding') const HANDLE = Symbol('IdMapIndex.handle') -function ensureHandle (self) { - if (self[HANDLE] == null) { +function ensureHandle(self) { + if (self[HANDLE] === null || self[HANDLE] === undefined) { throw new Error('IdMapIndex has been disposed') } return self[HANDLE] } /** - * In-memory TurboQuant-style ANN vector index. Indexes vectors of fixed - * dimensionality and stable external uint64 ids; supports top-k cosine / - * dot-product search. - * - * Scope: POC. Internals store full f32 vectors and do scalar dot products; - * quantization, SIMD, and GPU paths come later behind the same JS API. + * In-memory TurboVec-style vector index. Indexes vectors of fixed + * dimensionality and stable external uint64 ids; supports top-k dot-product + * search. Callers that need cosine similarity should L2-normalize vectors + * before both insertion and query. */ class IdMapIndex { /** * @param {object} opts * @param {number} opts.dim - vector dimensionality (must be > 0) - * @param {number} [opts.bitWidth=4] - reserved for future quantization + * @param {8|32} [opts.bitWidth=8] - 8 = q8 storage, 32 = f32 storage */ - constructor ({ dim, bitWidth = 4 } = {}) { + constructor({ dim, bitWidth = 8 } = {}) { if (!Number.isInteger(dim) || dim <= 0) { throw new TypeError('IdMapIndex: dim must be a positive integer') } - if (!Number.isInteger(bitWidth) || bitWidth <= 0 || bitWidth > 32) { - throw new TypeError('IdMapIndex: bitWidth must be an integer in [1, 32]') + if (bitWidth !== 8 && bitWidth !== 32) { + throw new TypeError('IdMapIndex: bitWidth must be 8 or 32') } this[HANDLE] = binding.idx_create({ dim, bitWidth }) } @@ -51,13 +49,13 @@ class IdMapIndex { * @param {string} path * @returns {Promise} */ - static async load (path) { + static load(path) { if (typeof path !== 'string' || path.length === 0) { throw new TypeError('IdMapIndex.load: path must be a non-empty string') } const instance = Object.create(IdMapIndex.prototype) instance[HANDLE] = binding.idx_load(path) - return instance + return Promise.resolve(instance) } /** @@ -66,19 +64,19 @@ class IdMapIndex { * @param {Float32Array} vectors - length = n * dim * @param {BigUint64Array} ids - length = n */ - addWithIds (vectors, ids) { + addWithIds(vectors, ids) { if (!(vectors instanceof Float32Array)) { throw new TypeError('addWithIds: vectors must be a Float32Array') } if (!(ids instanceof BigUint64Array)) { throw new TypeError('addWithIds: ids must be a BigUint64Array') } - if (ids.length === 0) return if (vectors.length !== ids.length * this.dim) { throw new RangeError( `addWithIds: vectors.length (${vectors.length}) must equal ids.length (${ids.length}) * dim (${this.dim})` ) } + if (ids.length === 0) return binding.idx_add(ensureHandle(this), vectors, ids) } @@ -90,7 +88,7 @@ class IdMapIndex { * @param {number} k * @returns {{ scores: Float32Array, ids: BigUint64Array, m: number, k: number }} */ - search (queries, k) { + search(queries, k) { if (!(queries instanceof Float32Array)) { throw new TypeError('search: queries must be a Float32Array') } @@ -105,7 +103,7 @@ class IdMapIndex { * @param {bigint} id * @returns {boolean} true if removed, false if not present */ - remove (id) { + remove(id) { if (typeof id !== 'bigint') { throw new TypeError('remove: id must be a bigint') } @@ -116,32 +114,38 @@ class IdMapIndex { * @param {bigint} id * @returns {boolean} */ - contains (id) { + contains(id) { if (typeof id !== 'bigint') { throw new TypeError('contains: id must be a bigint') } return binding.idx_contains(ensureHandle(this), id) } - /** No-op for the POC. Placeholder for future cache warming. */ - prepare () { + /** Placeholder for future cache warming. */ + prepare() { binding.idx_prepare(ensureHandle(this)) } /** - * Persist the index to disk in the .tvim v1 format. + * Persist the index to disk in the checksummed .tvim v2 format. * @param {string} path */ - write (path) { + write(path) { if (typeof path !== 'string' || path.length === 0) { throw new TypeError('write: path must be a non-empty string') } binding.idx_write(ensureHandle(this), path) } - get length () { return binding.idx_len(ensureHandle(this)) } - get dim () { return binding.idx_dim(ensureHandle(this)) } - get bitWidth () { return binding.idx_bit_width(ensureHandle(this)) } + get length() { + return binding.idx_len(ensureHandle(this)) + } + get dim() { + return binding.idx_dim(ensureHandle(this)) + } + get bitWidth() { + return binding.idx_bit_width(ensureHandle(this)) + } /** * Mark the instance as unusable. The native handle is owned by a @@ -152,8 +156,9 @@ class IdMapIndex { * a safe early-free path (wrapper object + sentinel) without changing * the call sites. */ - async dispose () { + dispose() { this[HANDLE] = null + return Promise.resolve() } } diff --git a/packages/embed-llamacpp/index.d.ts b/packages/embed-llamacpp/index.d.ts index 5b70102854..8a71eeafa9 100644 --- a/packages/embed-llamacpp/index.d.ts +++ b/packages/embed-llamacpp/index.d.ts @@ -89,18 +89,18 @@ export class BertInterface implements Addon { export function pickPrimaryGgufPath(files: string[]): string // --------------------------------------------------------------------------- -// IdMapIndex (turbovec POC) +// IdMapIndex (turbovec) // --------------------------------------------------------------------------- export interface IdMapIndexOptions { /** Vector dimensionality (must be > 0). */ dim: number - /** Reserved for future quantization; POC stores full f32 internally. */ - bitWidth?: number + /** Storage precision: 8 = q8 quantized storage, 32 = full f32 storage. Defaults to 8. */ + bitWidth?: 8 | 32 } export interface IdMapIndexSearchResult { - /** Row-major scores: m * k. Higher = closer. */ + /** Row-major dot-product scores: m * k. Higher = closer. */ scores: Float32Array /** Row-major external ids: m * k. `UINT64_MAX` padding when index is shorter than k. */ ids: BigUint64Array @@ -129,10 +129,10 @@ export class IdMapIndex { remove(id: bigint): boolean contains(id: bigint): boolean - /** No-op for the POC. */ + /** Placeholder for cache warming / codebook resolution after bulk add. */ prepare(): void - /** Persist to disk (.tvim v1). */ + /** Persist to disk (.tvim v2; legacy v1 files are still readable). */ write(path: string): void readonly length: number diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js index 1a380b8cc0..6f7947bfff 100644 --- a/packages/embed-llamacpp/test/integration/id-map-index.test.js +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -1,10 +1,8 @@ 'use strict' // -// Phase 2 acceptance: exercises every JS method on `IdMapIndex` end-to-end -// without loading any BERT model. Pair-bound with the C++ test under -// fabric's `tests/test-vector-index.cpp` (which exercises the same C API -// directly): if both pass, the JS↔C++ binding is correct in both -// directions. +// Exercises the production IdMapIndex JS surface end-to-end without loading +// any BERT model. Fabric's own tests cover the C API directly; these tests +// focus on the JS/native binding contract and packaging path. const test = require('brittle') const fs = require('bare-fs') @@ -22,10 +20,10 @@ const os = require('bare-os') // asserts the GGMLBert module file was never loaded. Bare keys // `require.cache` by `file://` URL, so we convert the resolved paths. const cacheKey = (p) => 'file://' + require.resolve(p) -const KEY_IDMAP = cacheKey('../../idMapIndex') +const KEY_IDMAP = cacheKey('../../idMapIndex') const KEY_BINDING = cacheKey('../../binding') -const KEY_INDEX = cacheKey('../../index.js') -const KEY_ADDON = cacheKey('../../addon.js') +const KEY_INDEX = cacheKey('../../index.js') +const KEY_ADDON = cacheKey('../../addon.js') const IdMapIndex = require('../../idMapIndex') @@ -34,47 +32,51 @@ const IdMapIndex = require('../../idMapIndex') // well-defined. const DIM = 16 const N = 10 +const UINT64_MAX = (1n << 64n) - 1n -function unitVec (i) { +function unitVec(i) { const v = new Float32Array(DIM) v[i] = 1 return v } let tmpCounter = 0 -function tmpPath (name) { +function tmpPath(name) { const pid = os.pid ? os.pid() : 0 tmpCounter += 1 - return path.join(os.tmpdir(), - `${name}-${pid}-${Date.now()}-${tmpCounter}.tvim`) + return path.join(os.tmpdir(), `${name}-${pid}-${Date.now()}-${tmpCounter}.tvim`) } -test('IdMapIndex sub-export does not boot the BERT runtime', (t) => { - // 1. Construct path: zero-arg + tiny-arg ctors must succeed without - // loading any model or running addon-side BERT init. - const idx = new IdMapIndex({ dim: DIM, bitWidth: 4 }) - t.is(idx.dim, DIM, 'dim getter') - t.is(idx.bitWidth, 4, 'bitWidth getter') - t.is(idx.length, 0, 'starts empty') - idx.dispose() +function expectThrows(t, fn, message) { + try { + fn() + t.fail(message) + } catch (e) { + t.pass(`${message}: ${e.message || e.code}`) + } +} - // 2. Module-cache invariant: requiring `./idMapIndex` must NOT have - // transitively loaded the GGMLBert entry (`./index.js`) or the - // BertInterface plumbing (`./addon.js`). If a future refactor wires - // BERT init into the IdMapIndex path, one of these files will be - // in the cache after the require above. - t.ok(require.cache[KEY_IDMAP], - 'sub-export module loaded (sanity)') - t.ok(require.cache[KEY_BINDING], - 'native binding loaded (sanity)') - t.absent(require.cache[KEY_INDEX], - 'GGMLBert entry (index.js) NOT loaded by ./idMapIndex') - t.absent(require.cache[KEY_ADDON], - 'BertInterface plumbing (addon.js) NOT loaded by ./idMapIndex') -}) +async function expectRejects(t, fn, message) { + try { + await fn() + t.fail(message) + } catch (e) { + t.pass(`${message}: ${e.message || e.code}`) + } +} -test('IdMapIndex: add + search + remove + persistence round-trip', async (t) => { - const idx = new IdMapIndex({ dim: DIM, bitWidth: 4 }) +function assertTvimV2Header(t, file, bitWidth) { + const bytes = fs.readFileSync(file) + t.is(bytes[0], 0x54, 'tvim magic T') + t.is(bytes[1], 0x56, 'tvim magic V') + t.is(bytes[2], 0x50, 'tvim magic P') + t.is(bytes[3], 0x49, 'tvim magic I') + t.is(bytes[4], 2, 'tvim version 2') + t.is(bytes[5], bitWidth, 'tvim bit width') +} + +async function runRoundTrip(t, bitWidth) { + const idx = new IdMapIndex({ dim: DIM, bitWidth }) const vectors = new Float32Array(N * DIM) const ids = new BigUint64Array(N) @@ -85,80 +87,126 @@ test('IdMapIndex: add + search + remove + persistence round-trip', async (t) => } idx.addWithIds(vectors, ids) - t.is(idx.length, N, 'all 10 vectors inserted') - t.ok(idx.contains(ids[3]), 'contains a known id') - t.absent(idx.contains(999n), 'absent id missing') + t.is(idx.length, N, `all vectors inserted (${bitWidth})`) + t.ok(idx.contains(ids[3]), `contains a known id (${bitWidth})`) + t.absent(idx.contains(999n), `absent id missing (${bitWidth})`) - // Top-1 of querying with the i-th unit vector must return that vector's id - // with a score very close to 1.0 (full f32, no quantization noise). + // Unit vectors remain exact for both f32 and q8 storage. for (let i = 0; i < N; i++) { - const q = unitVec(i) - const out = idx.search(q, 1) + const out = idx.search(unitVec(i), 1) t.is(out.m, 1) t.is(out.k, 1) - t.is(out.ids[0], ids[i], `query=${i} retrieves itself`) - t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5, `score≈1.0 for self-match`) + t.is(out.ids[0], ids[i], `query=${i} retrieves itself (${bitWidth})`) + t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5, `score≈1.0 for self-match (${bitWidth})`) } - // Top-k > length pads sentinels at the tail. { const out = idx.search(unitVec(0), N + 4) t.is(out.ids.length, N + 4) + t.is(out.scores.length, N + 4) for (let i = N; i < N + 4; i++) { - t.is(out.ids[i], (1n << 64n) - 1n, `sentinel id at tail slot ${i}`) + t.is(out.ids[i], UINT64_MAX, `sentinel id at tail slot ${i} (${bitWidth})`) + t.ok(out.scores[i] < -3e38, `sentinel score at tail slot ${i} (${bitWidth})`) } } - // Duplicate add must throw atomically (length unchanged). { const dupIds = new BigUint64Array([ids[0]]) - const dupVec = unitVec(0) - try { - idx.addWithIds(dupVec, dupIds) - t.fail('duplicate add should have thrown') - } catch (e) { - t.pass('duplicate add threw: ' + (e.message || e.code)) - } - t.is(idx.length, N, 'length unchanged after rejected dup add') + expectThrows( + t, + () => idx.addWithIds(unitVec(0), dupIds), + `duplicate add should throw (${bitWidth})` + ) + t.is(idx.length, N, `length unchanged after rejected dup add (${bitWidth})`) } - // Remove + search: the removed id should not surface. { const removed = idx.remove(ids[2]) - t.ok(removed === true, 'remove returns true on first call') - t.absent(idx.remove(ids[2]), 'remove returns false on second call') - t.absent(idx.contains(ids[2]), 'no longer contained') + t.ok(removed === true, `remove returns true on first call (${bitWidth})`) + t.absent(idx.remove(ids[2]), `remove returns false on second call (${bitWidth})`) + t.absent(idx.contains(ids[2]), `no longer contained (${bitWidth})`) t.is(idx.length, N - 1) const out = idx.search(unitVec(2), 3) for (let i = 0; i < 3; i++) { - t.absent(out.ids[i] === ids[2], `removed id absent from search result ${i}`) + t.absent(out.ids[i] === ids[2], `removed id absent from search result ${i} (${bitWidth})`) } } - // prepare() is a no-op but must be callable. idx.prepare() - // Persist, dispose, reload, re-search. - const file = tmpPath('id-map-index-roundtrip') + const file = tmpPath(`id-map-index-roundtrip-${bitWidth}`) try { idx.write(file) + assertTvimV2Header(t, file, bitWidth) await idx.dispose() const loaded = await IdMapIndex.load(file) - t.is(loaded.dim, DIM, 'dim restored') - t.is(loaded.bitWidth, 4, 'bitWidth restored') - t.is(loaded.length, N - 1, 'length restored (deletion persisted)') - t.ok(loaded.contains(ids[0]), 'kept id still there') - t.absent(loaded.contains(ids[2]), 'deleted id stayed deleted') + t.is(loaded.dim, DIM, `dim restored (${bitWidth})`) + t.is(loaded.bitWidth, bitWidth, `bitWidth restored (${bitWidth})`) + t.is(loaded.length, N - 1, `length restored (${bitWidth})`) + t.ok(loaded.contains(ids[0]), `kept id still there (${bitWidth})`) + t.absent(loaded.contains(ids[2]), `deleted id stayed deleted (${bitWidth})`) const out = loaded.search(unitVec(0), 1) - t.is(out.ids[0], ids[0], 'self-match after reload') - t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5) + t.is(out.ids[0], ids[0], `self-match after reload (${bitWidth})`) + t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5, `score≈1.0 after reload (${bitWidth})`) await loaded.dispose() } finally { if (fs.existsSync(file)) fs.unlinkSync(file) } +} + +test('IdMapIndex sub-export does not boot the BERT runtime', (t) => { + // 1. Construct path: zero-arg + tiny-arg ctors must succeed without + // loading any model or running addon-side BERT init. + const idx = new IdMapIndex({ dim: DIM }) + t.is(idx.dim, DIM, 'dim getter') + t.is(idx.bitWidth, 8, 'default bitWidth getter') + t.is(idx.length, 0, 'starts empty') + idx.dispose() + + // 2. Module-cache invariant: requiring `./idMapIndex` must NOT have + // transitively loaded the GGMLBert entry (`./index.js`) or the + // BertInterface plumbing (`./addon.js`). If a future refactor wires + // BERT init into the IdMapIndex path, one of these files will be + // in the cache after the require above. + t.ok(require.cache[KEY_IDMAP], 'sub-export module loaded (sanity)') + t.ok(require.cache[KEY_BINDING], 'native binding loaded (sanity)') + t.absent(require.cache[KEY_INDEX], 'GGMLBert entry (index.js) NOT loaded by ./idMapIndex') + t.absent(require.cache[KEY_ADDON], 'BertInterface plumbing (addon.js) NOT loaded by ./idMapIndex') +}) + +test('IdMapIndex: q8 add + search + remove + persistence round-trip', async (t) => { + await runRoundTrip(t, 8) +}) + +test('IdMapIndex: f32 add + search + remove + persistence round-trip', async (t) => { + await runRoundTrip(t, 32) +}) + +test('IdMapIndex: validates production bit widths', (t) => { + expectThrows(t, () => new IdMapIndex({ dim: DIM, bitWidth: 4 }), 'bitWidth 4 should be rejected') + expectThrows( + t, + () => new IdMapIndex({ dim: DIM, bitWidth: 16 }), + 'bitWidth 16 should be rejected' + ) +}) + +test('IdMapIndex: rejects mismatched empty-id add', (t) => { + const idx = new IdMapIndex({ dim: 2 }) + expectThrows( + t, + () => { + idx.addWithIds(new Float32Array([1, 2]), new BigUint64Array()) + }, + 'vectors with empty ids should be rejected' + ) + t.is(idx.length, 0, 'failed add does not mutate') + idx.addWithIds(new Float32Array(), new BigUint64Array()) + t.is(idx.length, 0, 'empty add remains a no-op') + idx.dispose() }) test('IdMapIndex: BigInt id range edge cases', (t) => { @@ -171,3 +219,33 @@ test('IdMapIndex: BigInt id range edge cases', (t) => { t.is(out.ids[0], edge, 'high-bit id surfaces from search') idx.dispose() }) + +test('IdMapIndex: rejects non-finite vectors and queries', (t) => { + const idx = new IdMapIndex({ dim: 2, bitWidth: 8 }) + expectThrows( + t, + () => { + idx.addWithIds(new Float32Array([1, Number.POSITIVE_INFINITY]), new BigUint64Array([1n])) + }, + 'non-finite vector should be rejected' + ) + t.is(idx.length, 0, 'failed add does not mutate') + + idx.addWithIds(new Float32Array([1, 0]), new BigUint64Array([1n])) + expectThrows( + t, + () => idx.search(new Float32Array([Number.NaN, 0]), 1), + 'non-finite query should be rejected' + ) + idx.dispose() +}) + +test('IdMapIndex: corrupt persistence file load fails', async (t) => { + const file = tmpPath('id-map-index-corrupt') + try { + fs.writeFileSync(file, new Uint8Array([0, 1, 2, 3, 4, 5])) + await expectRejects(t, () => IdMapIndex.load(file), 'corrupt tvim file should be rejected') + } finally { + if (fs.existsSync(file)) fs.unlinkSync(file) + } +}) diff --git a/packages/embed-llamacpp/vcpkg.json b/packages/embed-llamacpp/vcpkg.json index e1dd3fc8de..50c270b3de 100644 --- a/packages/embed-llamacpp/vcpkg.json +++ b/packages/embed-llamacpp/vcpkg.json @@ -1,5 +1,5 @@ { - "$schema-comment": "vcpkg requires `name` + a versioning field whenever the manifest uses `version>=` constraints or overlay-ports. Both are present here because the qvac-fabric pin (>=8828.1.0) goes through an overlay (vcpkg/ports/qvac-fabric).", + "$schema-comment": "vcpkg requires `name` + a versioning field whenever the manifest uses `version>=` constraints or overlay-ports. Both are present here because the qvac-fabric pin (>=9341.1.6) goes through an overlay (vcpkg/ports/qvac-fabric).", "name": "embed-llamacpp", "version-string": "0.19.1", "dependencies": [ diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake index baf73cb6d5..f6a8cd424c 100644 --- a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake @@ -1,18 +1,15 @@ -# LOCAL OVERLAY for qvac-fabric (turbovec POC). +# LOCAL OVERLAY for qvac-fabric (turbovec). # # Pinned by default to a commit on # https://github.com/dev-nid/qvac-fabric-llm.cpp.git -# (branch: poc/turbovec-embed) -# which adds the new ggml-vector-index module on top of upstream -# fabric's `v8828.1.1` (which is the highest 8828.x release tag that -# satisfies embed-llamacpp's `qvac-fabric >= 8828.1.0` manifest pin). +# (branch: turbovec) +# which carries the production ggml-vector-index implementation on top +# of the fabric line consumed by embed-llamacpp. # # Why an overlay at all: the public `tetherto/qvac-registry-vcpkg` port -# for qvac-fabric tops out at v7248.2.2 and has no 8828.x line yet, so -# the manifest's >=8828.1.0 constraint cannot be satisfied by the -# registry. This overlay closes that gap and is the single source of -# fabric source for the POC build. Drop it once the registry publishes -# an 8828.x port. +# does not yet carry the turbovec/vector-index fabric changes. This +# overlay is the temporary source of truth until that port is published +# in the registry. # # Local-edit iteration (Phase 0 fast loop) — set QVAC_FABRIC_LOCAL_PATH # to the path of a fabric working tree. The overlay then copies that @@ -25,10 +22,10 @@ # fabric-src-hash: 0000000000000000000000000000000000000000 set(FABRIC_GH_REPO "dev-nid/qvac-fabric-llm.cpp") -set(FABRIC_GH_REF "dd934a6d9964a718eaca6630c72dc33fe88a8892") # poc/turbovec-embed -set(FABRIC_GH_HEAD_REF "poc/turbovec-embed") +set(FABRIC_GH_REF "3a928cf584e0d1b3e98323c422f0d15218bf7b6b") # turbovec +set(FABRIC_GH_HEAD_REF "turbovec") set(FABRIC_GH_SHA512 - "ad696aa0382c207ce5ca3840b9ae0b2fd7ce85b458d0a472275e2fee933bb578539244c716f07ed087b718be0de1057139837d0ec57a8ac6b0eeafabe942fae5") + "a26df5e54ae0cb12ee272d86465221d312949ae520b250412388f4dea4a049dd1598eecaf37a49a22e9a444ca532ebb6bdd70239ddde0d201b3861fdb2e599ee") if(DEFINED ENV{QVAC_FABRIC_LOCAL_PATH}) set(FABRIC_LOCAL_PATH "$ENV{QVAC_FABRIC_LOCAL_PATH}") @@ -130,6 +127,7 @@ vcpkg_cmake_configure( -DLLAMA_BUILD_TOOLS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=OFF + -DLLAMA_BUILD_APP=OFF -DLLAMA_ALL_WARNINGS=OFF ${PLATFORM_OPTIONS} ${FEATURE_OPTIONS} diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json index 4753692f1f..26ef3abf22 100644 --- a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json @@ -1,8 +1,8 @@ { "name": "qvac-fabric", - "version": "8828.1.0", + "version": "9341.1.6", "port-version": 9999, - "description": "LLM inference in C/C++ (LOCAL OVERLAY built from sibling qvac-fabric-llm.cpp source tree)", + "description": "LLM inference in C/C++ (LOCAL OVERLAY with turbovec vector index support)", "homepage": "https://github.com/tetherto/qvac-fabric-llm.cpp", "license": "MIT", "dependencies": [ From 14daaa37c209183d703e2abd7a5d72feef35a2c1 Mon Sep 17 00:00:00 2001 From: Nidhin Date: Wed, 15 Jul 2026 05:33:38 +0530 Subject: [PATCH 03/11] feat: productionize IdMapIndex TurboVec integration - Update qvac-fabric overlay to the TurboVec production commit. - Add q4, mmap, compact, filtered search, prepared filters, IVF, and delta log APIs. - Harden native bindings for disposal, path handling, overflow checks, and typed-array validation. - Expand IdMapIndex integration coverage for persistence, filters, IVF, mmap, delta replay/compaction, and corrupt files. --- .../src/js-interface/vector-index-binding.cpp | 887 ++++++++++++++++-- .../addon/src/model-interface/VectorIndex.cpp | 113 +++ .../addon/src/model-interface/VectorIndex.hpp | 74 ++ packages/embed-llamacpp/idMapIndex.js | 230 ++++- packages/embed-llamacpp/index.d.ts | 55 +- .../test/integration/id-map-index.test.js | 267 +++++- .../vcpkg/ports/qvac-fabric/portfile.cmake | 5 +- 7 files changed, 1539 insertions(+), 92 deletions(-) diff --git a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp index f9a46c33db..9ff482c427 100644 --- a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include "../addon/VectorIndexErrors.hpp" @@ -27,38 +28,146 @@ namespace { using qvac_lib_infer_llamacpp_embed::VectorIndex; +using qvac_lib_infer_llamacpp_embed::VectorIndexFilter; namespace verrors = qvac_lib_infer_llamacpp_embed::vector_index_errors; +struct VectorIndexExternal { + explicit VectorIndexExternal(VectorIndex* index) noexcept : idx(index) {} + + ~VectorIndexExternal() { + delete idx; + idx = nullptr; + } + + VectorIndexExternal(const VectorIndexExternal&) = delete; + VectorIndexExternal& operator=(const VectorIndexExternal&) = delete; + + VectorIndex* idx; +}; + +struct SearchOutput { + size_t total = 0; + void* scoresData = nullptr; + js_value_t* scoresBuffer = nullptr; + void* idsData = nullptr; + js_value_t* idsBuffer = nullptr; +}; + +struct VectorIndexFilterExternal { + explicit VectorIndexFilterExternal(VectorIndexFilter* value) noexcept + : filter(value) {} + + ~VectorIndexFilterExternal() { + delete filter; + filter = nullptr; + } + + VectorIndexFilterExternal(const VectorIndexFilterExternal&) = delete; + VectorIndexFilterExternal& operator=(const VectorIndexFilterExternal&) = delete; + + VectorIndexFilter* filter; +}; + // Finalizer: invoked by the JS engine when the external handle is GC'd. // Tears down the native C handle via VectorIndex's RAII dtor. void finalize_vector_index(js_env_t* /*env*/, void* data, void* /*hint*/) { - auto* idx = static_cast(data); - delete idx; + auto* external = static_cast(data); + delete external; +} + +void +finalize_vector_index_filter(js_env_t* /*env*/, void* data, void* /*hint*/) { + auto* external = static_cast(data); + delete external; } // Wrap an already-constructed VectorIndex into a JS external. Takes // ownership of `idx`; the JS engine will delete it via finalize on GC. js_value_t* wrap(js_env_t* env, VectorIndex* idx) { + VectorIndexExternal* holder = nullptr; + try { + holder = new VectorIndexExternal(idx); + } catch (const std::bad_alloc&) { + delete idx; + js_throw_error(env, "OutOfMemory", "allocation failure"); + return nullptr; + } + js_value_t* external = nullptr; - if (js_create_external(env, idx, finalize_vector_index, nullptr, &external) + if (js_create_external(env, holder, finalize_vector_index, nullptr, &external) != 0) { - delete idx; + delete holder; + js_throw_error(env, "InternalError", "failed to create external"); + return nullptr; + } + return external; +} + +js_value_t* wrap_filter(js_env_t* env, VectorIndexFilter* filter) { + VectorIndexFilterExternal* holder = nullptr; + try { + holder = new VectorIndexFilterExternal(filter); + } catch (const std::bad_alloc&) { + delete filter; + js_throw_error(env, "OutOfMemory", "allocation failure"); + return nullptr; + } + + js_value_t* external = nullptr; + if (js_create_external( + env, holder, finalize_vector_index_filter, nullptr, &external) != 0) { + delete holder; js_throw_error(env, "InternalError", "failed to create external"); return nullptr; } return external; } +VectorIndexExternal* unwrap_external(js_env_t* env, js_value_t* handle) { + void* data = nullptr; + if (js_get_value_external(env, handle, &data) != 0 || data == nullptr) { + js_throw_error(env, "InvalidArgument", "expected IdMapIndex handle"); + return nullptr; + } + return static_cast(data); +} + // Get a borrowed pointer out of a JS external handle. Throws and returns // null on failure. VectorIndex* unwrap(js_env_t* env, js_value_t* handle) { + VectorIndexExternal* external = unwrap_external(env, handle); + if (external == nullptr) { + return nullptr; + } + if (external->idx == nullptr) { + js_throw_error(env, "InvalidArgument", "IdMapIndex has been disposed"); + return nullptr; + } + return external->idx; +} + +VectorIndexFilterExternal* unwrap_filter_external( + js_env_t* env, + js_value_t* handle) { void* data = nullptr; if (js_get_value_external(env, handle, &data) != 0 || data == nullptr) { - js_throw_error(env, "InvalidArgument", "expected IdMapIndex handle"); + js_throw_error(env, "InvalidArgument", "expected IdMapIndexFilter handle"); + return nullptr; + } + return static_cast(data); +} + +VectorIndexFilter* unwrap_filter(js_env_t* env, js_value_t* handle) { + VectorIndexFilterExternal* external = unwrap_filter_external(env, handle); + if (external == nullptr) { + return nullptr; + } + if (external->filter == nullptr) { + js_throw_error(env, "InvalidArgument", "IdMapIndexFilter has been disposed"); return nullptr; } - return static_cast(data); + return external->filter; } // Read a JS object property and parse as int32. On failure, throws and @@ -111,6 +220,105 @@ bool read_utf8_string( return true; } +bool create_search_output( + js_env_t* env, + const VectorIndex* idx, + size_t qlen, + int32_t k, + int* outM, + SearchOutput* out) { + const int dim = idx->dim(); + if (dim <= 0 || qlen % static_cast(dim) != 0) { + js_throw_range_error(env, "InvalidArgument", + "queries.length must be a multiple of dim"); + return false; + } + + const size_t m = qlen / static_cast(dim); + if (m > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many queries"); + return false; + } + + const size_t k_size = static_cast(k); + const size_t max_size = std::numeric_limits::max(); + if (m != 0 && k_size > max_size / m) { + js_throw_range_error(env, "InvalidArgument", "search result is too large"); + return false; + } + + const size_t total = m * k_size; + if (total > max_size / sizeof(uint64_t)) { + js_throw_range_error(env, "InvalidArgument", "search result is too large"); + return false; + } + + void* scores_data = nullptr; + js_value_t* scores_ab = nullptr; + if (js_create_arraybuffer( + env, total * sizeof(float), &scores_data, &scores_ab) != 0) { + js_throw_error(env, "OutOfMemory", "scores arraybuffer"); + return false; + } + + void* ids_data = nullptr; + js_value_t* ids_ab = nullptr; + if (js_create_arraybuffer( + env, total * sizeof(uint64_t), &ids_data, &ids_ab) != 0) { + js_throw_error(env, "OutOfMemory", "ids arraybuffer"); + return false; + } + + *outM = static_cast(m); + out->total = total; + out->scoresData = scores_data; + out->scoresBuffer = scores_ab; + out->idsData = ids_data; + out->idsBuffer = ids_ab; + return true; +} + +js_value_t* finish_search_result( + js_env_t* env, + const SearchOutput& output, + int m, + int32_t k) { + js_value_t* scores_ta = nullptr; + if (js_create_typedarray( + env, js_float32array, output.total, output.scoresBuffer, 0, + &scores_ta) != 0) { + js_throw_error(env, "InternalError", "create scores typedarray"); + return nullptr; + } + + js_value_t* ids_ta = nullptr; + if (js_create_typedarray( + env, js_biguint64array, output.total, output.idsBuffer, 0, + &ids_ta) != 0) { + js_throw_error(env, "InternalError", "create ids typedarray"); + return nullptr; + } + + js_value_t* result = nullptr; + if (js_create_object(env, &result) != 0) { + js_throw_error(env, "InternalError", "create result object"); + return nullptr; + } + if (js_set_named_property(env, result, "scores", scores_ta) != 0 + || js_set_named_property(env, result, "ids", ids_ta) != 0) { + js_throw_error(env, "InternalError", "set result fields"); + return nullptr; + } + + js_value_t* m_val = nullptr; + js_value_t* k_val = nullptr; + js_create_uint32(env, static_cast(m), &m_val); + js_create_int32(env, k, &k_val); + js_set_named_property(env, result, "m", m_val); + js_set_named_property(env, result, "k", k_val); + return result; +} + // --------------------------------------------------------------------------- // Bindings // --------------------------------------------------------------------------- @@ -183,6 +391,86 @@ js_value_t* idx_load(js_env_t* env, js_callback_info_t* info) { } } +// idx_load_mmap(path) -> external handle (throws on file errors). +js_value_t* idx_load_mmap(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 1) { + js_throw_type_error(env, "InvalidArgument", "expected path string"); + return nullptr; + } + std::string path; + if (!read_utf8_string( + env, argv[0], + "path must be a string", + "failed to read path string", + &path)) { + return nullptr; + } + + VectorIndex loaded = VectorIndex::loadMmap(path); + if (!loaded.valid()) { + js_throw_error(env, "IOError", "ggml_vec_index_load_mmap returned null"); + return nullptr; + } + try { + // Move the wrapper onto the heap so we can hand JS an owning external. + auto* heap = new VectorIndex(std::move(loaded)); + return wrap(env, heap); + } catch (const std::bad_alloc&) { + js_throw_error(env, "OutOfMemory", "allocation failure"); + return nullptr; + } +} + +// idx_load_with_delta(snapshotPath, deltaPath) -> external handle. +js_value_t* idx_load_with_delta(js_env_t* env, js_callback_info_t* info) { + size_t argc = 2; + js_value_t* argv[2] = { nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 2) { + js_throw_type_error(env, "InvalidArgument", + "expected (snapshotPath, deltaPath)"); + return nullptr; + } + + std::string snapshot_path; + if (!read_utf8_string( + env, argv[0], + "snapshotPath must be a string", + "failed to read snapshot path string", + &snapshot_path)) { + return nullptr; + } + std::string delta_path; + if (!read_utf8_string( + env, argv[1], + "deltaPath must be a string", + "failed to read delta path string", + &delta_path)) { + return nullptr; + } + + VectorIndex loaded = VectorIndex::loadWithDelta(snapshot_path, delta_path); + if (!loaded.valid()) { + js_throw_error(env, "IOError", + "ggml_vec_index_load_with_delta returned null"); + return nullptr; + } + try { + auto* heap = new VectorIndex(std::move(loaded)); + return wrap(env, heap); + } catch (const std::bad_alloc&) { + js_throw_error(env, "OutOfMemory", "allocation failure"); + return nullptr; + } +} + // idx_add(handle, Float32Array vectors, BigUint64Array ids) -> undefined js_value_t* idx_add(js_env_t* env, js_callback_info_t* info) { size_t argc = 3; @@ -250,121 +538,412 @@ js_value_t* idx_add(js_env_t* env, js_callback_info_t* info) { return u; } -// idx_search(handle, Float32Array queries, int k) -// -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } -js_value_t* idx_search(js_env_t* env, js_callback_info_t* info) { - size_t argc = 3; - js_value_t* argv[3] = { nullptr, nullptr, nullptr }; +// idx_add_logged(handle, Float32Array vectors, BigUint64Array ids, deltaPath) +// -> undefined +js_value_t* idx_add_logged(js_env_t* env, js_callback_info_t* info) { + size_t argc = 4; + js_value_t* argv[4] = { nullptr, nullptr, nullptr, nullptr }; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } - if (argc < 3) { + if (argc < 4) { js_throw_type_error(env, "InvalidArgument", - "expected (handle, queries:Float32Array, k:number)"); + "expected (handle, vectors:Float32Array, ids:BigUint64Array, deltaPath)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); if (idx == nullptr) { return nullptr; } - js_typedarray_type_t qtype{}; - void* qdata = nullptr; - size_t qlen = 0; + js_typedarray_type_t vtype{}; + void* vdata = nullptr; + size_t vlen = 0; if (js_get_typedarray_info( - env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 - || qtype != js_float32array) { + env, argv[1], &vtype, &vdata, &vlen, nullptr, nullptr) != 0 + || vtype != js_float32array) { js_throw_type_error(env, "InvalidArgument", - "queries must be a Float32Array"); + "vectors must be a Float32Array"); return nullptr; } - int32_t k = 0; - if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { - js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + js_typedarray_type_t itype{}; + void* idata = nullptr; + size_t ilen = 0; + if (js_get_typedarray_info( + env, argv[2], &itype, &idata, &ilen, nullptr, nullptr) != 0 + || itype != js_biguint64array) { + js_throw_type_error(env, "InvalidArgument", + "ids must be a BigUint64Array"); + return nullptr; + } + + std::string delta_path; + if (!read_utf8_string( + env, argv[3], + "deltaPath must be a string", + "failed to read delta path", + &delta_path)) { return nullptr; } const int dim = idx->dim(); - if (dim <= 0 || qlen % static_cast(dim) != 0) { - js_throw_range_error(env, "InvalidArgument", - "queries.length must be a multiple of dim"); + if (dim <= 0) { + js_throw_error(env, "InternalError", "index has invalid dim"); return nullptr; } - const size_t m = qlen / static_cast(dim); - if (m > static_cast(INT32_MAX)) { - js_throw_range_error(env, "InvalidArgument", "too many queries"); + const size_t dim_size = static_cast(dim); + if (ilen > std::numeric_limits::max() / dim_size + || vlen != ilen * dim_size) { + js_throw_range_error(env, "InvalidArgument", + "vectors.length must equal ids.length * dim"); return nullptr; } - const size_t k_size = static_cast(k); - const size_t max_size = std::numeric_limits::max(); - if (m != 0 && k_size > max_size / m) { - js_throw_range_error(env, "InvalidArgument", "search result is too large"); + if (ilen > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many vectors in batch"); return nullptr; } - // Allocate output ArrayBuffers; we'll hand them to JS via typed-array - // views. - const size_t total = m * k_size; - if (total > max_size / sizeof(uint64_t)) { - js_throw_range_error(env, "InvalidArgument", "search result is too large"); + const int rc = idx->addLogged( + static_cast(vdata), + static_cast(ilen), + static_cast(idata), + delta_path); + if (rc != 0) { + throw_status(env, rc); return nullptr; } - const size_t scores_b = total * sizeof(float); - const size_t ids_b = total * sizeof(uint64_t); - - void* scores_data = nullptr; - js_value_t* scores_ab = nullptr; - if (js_create_arraybuffer(env, scores_b, &scores_data, &scores_ab) != 0) { - js_throw_error(env, "OutOfMemory", "scores arraybuffer"); + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// idx_search(handle, Float32Array queries, int k) +// -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } +js_value_t* idx_search(js_env_t* env, js_callback_info_t* info) { + size_t argc = 3; + js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } - void* ids_data = nullptr; - js_value_t* ids_ab = nullptr; - if (js_create_arraybuffer(env, ids_b, &ids_data, &ids_ab) != 0) { - js_throw_error(env, "OutOfMemory", "ids arraybuffer"); + if (argc < 3) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, queries:Float32Array, k:number)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + js_typedarray_type_t qtype{}; + void* qdata = nullptr; + size_t qlen = 0; + if (js_get_typedarray_info( + env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 + || qtype != js_float32array) { + js_throw_type_error(env, "InvalidArgument", + "queries must be a Float32Array"); + return nullptr; + } + + int32_t k = 0; + if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { + js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + return nullptr; + } + + int m = 0; + SearchOutput output; + if (!create_search_output(env, idx, qlen, k, &m, &output)) { return nullptr; } const int rc = idx->search( static_cast(qdata), - static_cast(m), + m, k, - static_cast(scores_data), - static_cast(ids_data)); + static_cast(output.scoresData), + static_cast(output.idsData)); if (rc != 0) { throw_status(env, rc); return nullptr; } - js_value_t* scores_ta = nullptr; - if (js_create_typedarray( - env, js_float32array, total, scores_ab, 0, &scores_ta) != 0) { - js_throw_error(env, "InternalError", "create scores typedarray"); + return finish_search_result(env, output, m, k); +} + +// idx_search_filtered(handle, Float32Array queries, int k, BigUint64Array ids) +// -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } +js_value_t* idx_search_filtered(js_env_t* env, js_callback_info_t* info) { + size_t argc = 4; + js_value_t* argv[4] = { nullptr, nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } - js_value_t* ids_ta = nullptr; - if (js_create_typedarray( - env, js_biguint64array, total, ids_ab, 0, &ids_ta) != 0) { - js_throw_error(env, "InternalError", "create ids typedarray"); + if (argc < 4) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, queries:Float32Array, k:number, allowedIds:BigUint64Array)"); return nullptr; } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } - js_value_t* result = nullptr; - if (js_create_object(env, &result) != 0) { - js_throw_error(env, "InternalError", "create result object"); + js_typedarray_type_t qtype{}; + void* qdata = nullptr; + size_t qlen = 0; + if (js_get_typedarray_info( + env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 + || qtype != js_float32array) { + js_throw_type_error(env, "InvalidArgument", + "queries must be a Float32Array"); return nullptr; } - if (js_set_named_property(env, result, "scores", scores_ta) != 0 - || js_set_named_property(env, result, "ids", ids_ta) != 0) { - js_throw_error(env, "InternalError", "set result fields"); + + int32_t k = 0; + if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { + js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); return nullptr; } - js_value_t* m_val = nullptr; - js_value_t* k_val = nullptr; - js_create_uint32(env, static_cast(m), &m_val); - js_create_int32(env, k, &k_val); - js_set_named_property(env, result, "m", m_val); - js_set_named_property(env, result, "k", k_val); - return result; + + js_typedarray_type_t atype{}; + void* adata = nullptr; + size_t alen = 0; + if (js_get_typedarray_info( + env, argv[3], &atype, &adata, &alen, nullptr, nullptr) != 0 + || atype != js_biguint64array) { + js_throw_type_error(env, "InvalidArgument", + "allowedIds must be a BigUint64Array"); + return nullptr; + } + if (alen > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many allowed ids"); + return nullptr; + } + + int m = 0; + SearchOutput output; + if (!create_search_output(env, idx, qlen, k, &m, &output)) { + return nullptr; + } + + const int rc = idx->searchFiltered( + static_cast(qdata), + m, + k, + alen == 0 ? nullptr : static_cast(adata), + static_cast(alen), + static_cast(output.scoresData), + static_cast(output.idsData)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + return finish_search_result(env, output, m, k); +} + +// idx_filter_create(handle, BigUint64Array allowedIds) -> external filter +js_value_t* idx_filter_create(js_env_t* env, js_callback_info_t* info) { + size_t argc = 2; + js_value_t* argv[2] = { nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 2) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, allowedIds:BigUint64Array)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + js_typedarray_type_t atype{}; + void* adata = nullptr; + size_t alen = 0; + if (js_get_typedarray_info( + env, argv[1], &atype, &adata, &alen, nullptr, nullptr) != 0 + || atype != js_biguint64array) { + js_throw_type_error(env, "InvalidArgument", + "allowedIds must be a BigUint64Array"); + return nullptr; + } + if (alen > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many allowed ids"); + return nullptr; + } + + VectorIndexFilter filter = idx->createFilter( + alen == 0 ? nullptr : static_cast(adata), + static_cast(alen)); + if (!filter.valid()) { + js_throw_error(env, "InvalidArgument", + "ggml_vec_index_filter_create returned null"); + return nullptr; + } + + try { + auto* heap = new VectorIndexFilter(std::move(filter)); + return wrap_filter(env, heap); + } catch (const std::bad_alloc&) { + js_throw_error(env, "OutOfMemory", "allocation failure"); + return nullptr; + } +} + +// idx_search_prepared_filtered(handle, filter, Float32Array queries, int k) +// -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } +js_value_t* idx_search_prepared_filtered( + js_env_t* env, + js_callback_info_t* info) { + size_t argc = 4; + js_value_t* argv[4] = { nullptr, nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 4) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, filter, queries:Float32Array, k:number)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + VectorIndexFilter* filter = unwrap_filter(env, argv[1]); + if (filter == nullptr) { return nullptr; } + + js_typedarray_type_t qtype{}; + void* qdata = nullptr; + size_t qlen = 0; + if (js_get_typedarray_info( + env, argv[2], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 + || qtype != js_float32array) { + js_throw_type_error(env, "InvalidArgument", + "queries must be a Float32Array"); + return nullptr; + } + + int32_t k = 0; + if (js_get_value_int32(env, argv[3], &k) != 0 || k <= 0) { + js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + return nullptr; + } + + int m = 0; + SearchOutput output; + if (!create_search_output(env, idx, qlen, k, &m, &output)) { + return nullptr; + } + + const int rc = idx->searchPreparedFiltered( + *filter, + static_cast(qdata), + m, + k, + static_cast(output.scoresData), + static_cast(output.idsData)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + return finish_search_result(env, output, m, k); +} + +// idx_build_ivf(handle, nLists:number, nIter:number) -> undefined +js_value_t* idx_build_ivf(js_env_t* env, js_callback_info_t* info) { + size_t argc = 3; + js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 3) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, nLists:number, nIter:number)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + int32_t n_lists = 0; + if (js_get_value_int32(env, argv[1], &n_lists) != 0 || n_lists <= 0) { + js_throw_type_error(env, "InvalidArgument", + "nLists must be a positive int"); + return nullptr; + } + + int32_t n_iter = 0; + if (js_get_value_int32(env, argv[2], &n_iter) != 0 || n_iter < 0) { + js_throw_type_error(env, "InvalidArgument", + "nIter must be a non-negative int"); + return nullptr; + } + + const int rc = idx->buildIvf(n_lists, n_iter); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// idx_search_ivf(handle, Float32Array queries, int k, int nProbe) +// -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } +js_value_t* idx_search_ivf(js_env_t* env, js_callback_info_t* info) { + size_t argc = 4; + js_value_t* argv[4] = { nullptr, nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 4) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, queries:Float32Array, k:number, nProbe:number)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + js_typedarray_type_t qtype{}; + void* qdata = nullptr; + size_t qlen = 0; + if (js_get_typedarray_info( + env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 + || qtype != js_float32array) { + js_throw_type_error(env, "InvalidArgument", + "queries must be a Float32Array"); + return nullptr; + } + + int32_t k = 0; + if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { + js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + return nullptr; + } + + int32_t n_probe = 0; + if (js_get_value_int32(env, argv[3], &n_probe) != 0 || n_probe <= 0) { + js_throw_type_error(env, "InvalidArgument", + "nProbe must be a positive int"); + return nullptr; + } + + int m = 0; + SearchOutput output; + if (!create_search_output(env, idx, qlen, k, &m, &output)) { + return nullptr; + } + + const int rc = idx->searchIvf( + static_cast(qdata), + m, + k, + n_probe, + static_cast(output.scoresData), + static_cast(output.idsData)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + return finish_search_result(env, output, m, k); } // idx_remove(handle, id:bigint) -> boolean @@ -401,6 +980,69 @@ js_value_t* idx_remove(js_env_t* env, js_callback_info_t* info) { return result; } +// idx_remove_logged(handle, id:bigint, deltaPath) -> boolean +js_value_t* idx_remove_logged(js_env_t* env, js_callback_info_t* info) { + size_t argc = 3; + js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 3) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, id:bigint, deltaPath)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + uint64_t id = 0; + bool lossless = false; + if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 + || !lossless) { + js_throw_type_error(env, "InvalidArgument", + "id must be an unsigned BigInt fitting in 64 bits"); + return nullptr; + } + + std::string delta_path; + if (!read_utf8_string( + env, argv[2], + "deltaPath must be a string", + "failed to read delta path", + &delta_path)) { + return nullptr; + } + + const int rc = idx->removeLogged(id, delta_path); + if (rc < 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* result = nullptr; + js_get_boolean(env, rc == 1, &result); + return result; +} + +// idx_compact(handle) -> undefined +js_value_t* idx_compact(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + const int rc = idx->compact(); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + // idx_contains(handle, id:bigint) -> boolean js_value_t* idx_contains(js_env_t* env, js_callback_info_t* info) { size_t argc = 2; @@ -477,6 +1119,94 @@ js_value_t* idx_write(js_env_t* env, js_callback_info_t* info) { return u; } +// idx_compact_delta(handle, snapshotPath, deltaPath) -> undefined. +js_value_t* idx_compact_delta(js_env_t* env, js_callback_info_t* info) { + size_t argc = 3; + js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 3) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, snapshotPath, deltaPath)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + std::string snapshot_path; + if (!read_utf8_string( + env, argv[1], + "snapshotPath must be a string", + "failed to read snapshot path", + &snapshot_path)) { + return nullptr; + } + std::string delta_path; + if (!read_utf8_string( + env, argv[2], + "deltaPath must be a string", + "failed to read delta path", + &delta_path)) { + return nullptr; + } + + const int rc = idx->compactDelta(snapshot_path, delta_path); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// idx_dispose(handle) -> undefined. Frees the native index immediately; the +// JS external finalizer remains safe and becomes a no-op for the index. +js_value_t* idx_dispose(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 1) { + js_throw_type_error(env, "InvalidArgument", "expected handle"); + return nullptr; + } + VectorIndexExternal* external = unwrap_external(env, argv[0]); + if (external == nullptr) { return nullptr; } + + delete external->idx; + external->idx = nullptr; + + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// idx_filter_dispose(filter) -> undefined. +js_value_t* idx_filter_dispose(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 1) { + js_throw_type_error(env, "InvalidArgument", "expected filter handle"); + return nullptr; + } + VectorIndexFilterExternal* external = unwrap_filter_external(env, argv[0]); + if (external == nullptr) { return nullptr; } + + delete external->filter; + external->filter = nullptr; + + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + // Generic int32 getter for len/dim/bitWidth. template js_value_t* idx_int_getter(js_env_t* env, js_callback_info_t* info) { @@ -511,12 +1241,25 @@ void registerBindings(js_env_t* env, js_value_t* exports) { V("idx_create", idx_create); V("idx_load", idx_load); + V("idx_load_mmap", idx_load_mmap); + V("idx_load_with_delta", idx_load_with_delta); V("idx_add", idx_add); + V("idx_add_logged", idx_add_logged); V("idx_search", idx_search); + V("idx_search_filtered", idx_search_filtered); + V("idx_filter_create", idx_filter_create); + V("idx_search_prepared_filtered", idx_search_prepared_filtered); + V("idx_build_ivf", idx_build_ivf); + V("idx_search_ivf", idx_search_ivf); V("idx_remove", idx_remove); + V("idx_remove_logged", idx_remove_logged); + V("idx_compact", idx_compact); V("idx_contains", idx_contains); V("idx_prepare", idx_prepare); V("idx_write", idx_write); + V("idx_compact_delta", idx_compact_delta); + V("idx_dispose", idx_dispose); + V("idx_filter_dispose", idx_filter_dispose); V("idx_len", (idx_int_getter<&VectorIndex::len>)); V("idx_dim", (idx_int_getter<&VectorIndex::dim>)); V("idx_bit_width", (idx_int_getter<&VectorIndex::bitWidth>)); diff --git a/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp index bfd829b571..3f6763d3d1 100644 --- a/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp +++ b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp @@ -5,6 +5,34 @@ namespace qvac_lib_infer_llamacpp_embed { +VectorIndexFilter::VectorIndexFilter( + ggml_vec_index_filter_t* handle) noexcept + : handle_(handle) {} + +VectorIndexFilter::~VectorIndexFilter() { + if (handle_ != nullptr) { + ggml_vec_index_filter_free(handle_); + handle_ = nullptr; + } +} + +VectorIndexFilter::VectorIndexFilter(VectorIndexFilter&& other) noexcept + : handle_(other.handle_) { + other.handle_ = nullptr; +} + +VectorIndexFilter& VectorIndexFilter::operator=( + VectorIndexFilter&& other) noexcept { + if (this != &other) { + if (handle_ != nullptr) { + ggml_vec_index_filter_free(handle_); + } + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; +} + VectorIndex::VectorIndex(int dim, int bitWidth) : handle_(ggml_vec_index_create(dim, bitWidth)) { if (handle_ == nullptr) { @@ -43,16 +71,39 @@ int VectorIndex::add( return ggml_vec_index_add(handle_, vectors, n, ids); } +int VectorIndex::addLogged( + const float* vectors, + int n, + const uint64_t* ids, + const std::string& deltaPath) noexcept { + return ggml_vec_index_add_logged( + handle_, vectors, n, ids, deltaPath.c_str()); +} + int VectorIndex::remove(uint64_t id) noexcept { return ggml_vec_index_remove(handle_, id); } +int VectorIndex::removeLogged( + uint64_t id, + const std::string& deltaPath) noexcept { + return ggml_vec_index_remove_logged(handle_, id, deltaPath.c_str()); +} + +int VectorIndex::compact() noexcept { + return ggml_vec_index_compact(handle_); +} + bool VectorIndex::contains(uint64_t id) const noexcept { return ggml_vec_index_contains(handle_, id) != 0; } void VectorIndex::prepare() noexcept { ggml_vec_index_prepare(handle_); } +int VectorIndex::buildIvf(int nLists, int nIter) noexcept { + return ggml_vec_index_build_ivf(handle_, nLists, nIter); +} + int VectorIndex::search( const float* queries, int n_q, @@ -63,15 +114,77 @@ int VectorIndex::search( handle_, queries, n_q, k, outScores, outIds); } +int VectorIndex::searchFiltered( + const float* queries, + int n_q, + int k, + const uint64_t* allowedIds, + int nAllowed, + float* outScores, + uint64_t* outIds) const noexcept { + return ggml_vec_index_search_filtered( + handle_, queries, n_q, k, allowedIds, nAllowed, outScores, outIds); +} + +VectorIndexFilter VectorIndex::createFilter( + const uint64_t* allowedIds, + int nAllowed) const noexcept { + ggml_vec_index_filter_t* raw = ggml_vec_index_filter_create( + handle_, allowedIds, nAllowed); + return VectorIndexFilter(raw); +} + +int VectorIndex::searchPreparedFiltered( + const VectorIndexFilter& filter, + const float* queries, + int n_q, + int k, + float* outScores, + uint64_t* outIds) const noexcept { + return ggml_vec_index_search_prepared_filtered( + handle_, filter.raw(), queries, n_q, k, outScores, outIds); +} + +int VectorIndex::searchIvf( + const float* queries, + int n_q, + int k, + int nProbe, + float* outScores, + uint64_t* outIds) const noexcept { + return ggml_vec_index_search_ivf( + handle_, queries, n_q, k, nProbe, outScores, outIds); +} + int VectorIndex::write(const std::string& path) noexcept { return ggml_vec_index_write(handle_, path.c_str()); } +int VectorIndex::compactDelta( + const std::string& snapshotPath, + const std::string& deltaPath) noexcept { + return ggml_vec_index_compact_delta( + handle_, snapshotPath.c_str(), deltaPath.c_str()); +} + VectorIndex VectorIndex::load(const std::string& path) noexcept { ggml_vec_index_t* raw = ggml_vec_index_load(path.c_str()); return VectorIndex(raw); } +VectorIndex VectorIndex::loadWithDelta( + const std::string& snapshotPath, + const std::string& deltaPath) noexcept { + ggml_vec_index_t* raw = ggml_vec_index_load_with_delta( + snapshotPath.c_str(), deltaPath.c_str()); + return VectorIndex(raw); +} + +VectorIndex VectorIndex::loadMmap(const std::string& path) noexcept { + ggml_vec_index_t* raw = ggml_vec_index_load_mmap(path.c_str()); + return VectorIndex(raw); +} + int VectorIndex::len() const noexcept { return ggml_vec_index_len(handle_); } diff --git a/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp index bd6fbc48fe..9f81a52d9e 100644 --- a/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp +++ b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp @@ -12,6 +12,25 @@ namespace qvac_lib_infer_llamacpp_embed { +class VectorIndexFilter { +public: + explicit VectorIndexFilter(ggml_vec_index_filter_t* handle) noexcept; + ~VectorIndexFilter(); + + VectorIndexFilter(const VectorIndexFilter&) = delete; + VectorIndexFilter& operator=(const VectorIndexFilter&) = delete; + VectorIndexFilter(VectorIndexFilter&& other) noexcept; + VectorIndexFilter& operator=(VectorIndexFilter&& other) noexcept; + + [[nodiscard]] bool valid() const noexcept { return handle_ != nullptr; } + [[nodiscard]] ggml_vec_index_filter_t* raw() const noexcept { + return handle_; + } + +private: + ggml_vec_index_filter_t* handle_; +}; + class VectorIndex { public: // Construct a fresh empty index. Throws std::invalid_argument on bad @@ -31,13 +50,26 @@ class VectorIndex { // Returns 0 on success, ggml_vec_index_error on failure (e.g. duplicate). int add(const float* vectors, int n, const uint64_t* ids) noexcept; + int addLogged( + const float* vectors, + int n, + const uint64_t* ids, + const std::string& deltaPath) noexcept; + // Returns 1 / 0 (removed / not present), negative on error. int remove(uint64_t id) noexcept; + int removeLogged(uint64_t id, const std::string& deltaPath) noexcept; + + // Physically removes deleted slots from in-memory storage. + int compact() noexcept; + [[nodiscard]] bool contains(uint64_t id) const noexcept; void prepare() noexcept; + int buildIvf(int nLists, int nIter) noexcept; + // Top-k search. Caller owns out arrays of size n_q * k. int search( const float* queries, @@ -46,13 +78,55 @@ class VectorIndex { float* outScores, uint64_t* outIds) const noexcept; + // Top-k search restricted to the caller-supplied allowlist. + int searchFiltered( + const float* queries, + int n_q, + int k, + const uint64_t* allowedIds, + int nAllowed, + float* outScores, + uint64_t* outIds) const noexcept; + + // Creates a reusable filter for repeated allowlist searches. + VectorIndexFilter createFilter( + const uint64_t* allowedIds, + int nAllowed) const noexcept; + + int searchPreparedFiltered( + const VectorIndexFilter& filter, + const float* queries, + int n_q, + int k, + float* outScores, + uint64_t* outIds) const noexcept; + + int searchIvf( + const float* queries, + int n_q, + int k, + int nProbe, + float* outScores, + uint64_t* outIds) const noexcept; + // Persists to disk. Returns 0 on success. int write(const std::string& path) noexcept; + int compactDelta( + const std::string& snapshotPath, + const std::string& deltaPath) noexcept; + // Reads from disk. On failure returns a wrapper whose `valid()` is false; // callers must check before using the instance. static VectorIndex load(const std::string& path) noexcept; + static VectorIndex loadWithDelta( + const std::string& snapshotPath, + const std::string& deltaPath) noexcept; + + // Reads from disk with mmap-backed vector storage. Mutations fail. + static VectorIndex loadMmap(const std::string& path) noexcept; + // Stats. [[nodiscard]] int len() const noexcept; [[nodiscard]] int dim() const noexcept; diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js index 96b0f85267..361cb706fc 100644 --- a/packages/embed-llamacpp/idMapIndex.js +++ b/packages/embed-llamacpp/idMapIndex.js @@ -2,8 +2,8 @@ // IdMapIndex: thin JS wrapper around the IdMapIndex N-API bindings exposed // by the embed-llamacpp addon (vector-index-binding.cpp). The native side -// supports full f32 storage (`bitWidth: 32`) and production q8 storage -// (`bitWidth: 8`) with CPU search against the selected representation. +// supports full f32 storage (`bitWidth: 32`) and production q8/q4 storage +// (`bitWidth: 8` or `4`) with CPU search against the selected representation. // // Lifecycle isolation: constructing IdMapIndex does NOT load any BERT // model. It only resolves the native binding (loaded once per process) @@ -14,6 +14,8 @@ const binding = require('./binding') const HANDLE = Symbol('IdMapIndex.handle') +const FILTER_HANDLE = Symbol('IdMapIndexFilter.handle') +const FILTER_OWNER = Symbol('IdMapIndexFilter.owner') function ensureHandle(self) { if (self[HANDLE] === null || self[HANDLE] === undefined) { @@ -22,6 +24,40 @@ function ensureHandle(self) { return self[HANDLE] } +function ensureFilterHandle(self) { + if (self[FILTER_HANDLE] === null || self[FILTER_HANDLE] === undefined) { + throw new Error('IdMapIndexFilter has been disposed') + } + return self[FILTER_HANDLE] +} + +class IdMapIndexFilter { + search(queries, k) { + if (!(queries instanceof Float32Array)) { + throw new TypeError('IdMapIndexFilter.search: queries must be a Float32Array') + } + if (!Number.isInteger(k) || k <= 0) { + throw new TypeError('IdMapIndexFilter.search: k must be a positive integer') + } + const filterHandle = ensureFilterHandle(this) + return binding.idx_search_prepared_filtered( + ensureHandle(this[FILTER_OWNER]), + filterHandle, + queries, + k + ) + } + + dispose() { + if (this[FILTER_HANDLE] !== null && this[FILTER_HANDLE] !== undefined) { + binding.idx_filter_dispose(this[FILTER_HANDLE]) + this[FILTER_HANDLE] = null + this[FILTER_OWNER] = null + } + return Promise.resolve() + } +} + /** * In-memory TurboVec-style vector index. Indexes vectors of fixed * dimensionality and stable external uint64 ids; supports top-k dot-product @@ -32,14 +68,14 @@ class IdMapIndex { /** * @param {object} opts * @param {number} opts.dim - vector dimensionality (must be > 0) - * @param {8|32} [opts.bitWidth=8] - 8 = q8 storage, 32 = f32 storage + * @param {4|8|32} [opts.bitWidth=8] - 4 = q4, 8 = q8, 32 = f32 storage */ constructor({ dim, bitWidth = 8 } = {}) { if (!Number.isInteger(dim) || dim <= 0) { throw new TypeError('IdMapIndex: dim must be a positive integer') } - if (bitWidth !== 8 && bitWidth !== 32) { - throw new TypeError('IdMapIndex: bitWidth must be 8 or 32') + if (bitWidth !== 4 && bitWidth !== 8 && bitWidth !== 32) { + throw new TypeError('IdMapIndex: bitWidth must be 4, 8, or 32') } this[HANDLE] = binding.idx_create({ dim, bitWidth }) } @@ -58,6 +94,39 @@ class IdMapIndex { return Promise.resolve(instance) } + /** + * Load a persisted .tvim file with mmap-backed vector storage. The returned + * index is read-only for mutating operations. + * @param {string} path + * @returns {Promise} + */ + static loadMmap(path) { + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('IdMapIndex.loadMmap: path must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load_mmap(path) + return Promise.resolve(instance) + } + + /** + * Load a full snapshot and replay an append-only delta log. + * @param {string} snapshotPath + * @param {string} deltaPath + * @returns {Promise} + */ + static loadWithDelta(snapshotPath, deltaPath) { + if (typeof snapshotPath !== 'string' || snapshotPath.length === 0) { + throw new TypeError('IdMapIndex.loadWithDelta: snapshotPath must be a non-empty string') + } + if (typeof deltaPath !== 'string' || deltaPath.length === 0) { + throw new TypeError('IdMapIndex.loadWithDelta: deltaPath must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load_with_delta(snapshotPath, deltaPath) + return Promise.resolve(instance) + } + /** * Insert `n` vectors with their external ids. Throws on duplicate id or * dim mismatch; partial mutation is impossible (atomic add). @@ -80,6 +149,31 @@ class IdMapIndex { binding.idx_add(ensureHandle(this), vectors, ids) } + /** + * Insert vectors and append the mutation to an incremental delta log. + * @param {Float32Array} vectors - length = n * dim + * @param {BigUint64Array} ids - length = n + * @param {string} deltaPath + */ + addLogged(vectors, ids, deltaPath) { + if (!(vectors instanceof Float32Array)) { + throw new TypeError('addLogged: vectors must be a Float32Array') + } + if (!(ids instanceof BigUint64Array)) { + throw new TypeError('addLogged: ids must be a BigUint64Array') + } + if (typeof deltaPath !== 'string' || deltaPath.length === 0) { + throw new TypeError('addLogged: deltaPath must be a non-empty string') + } + if (vectors.length !== ids.length * this.dim) { + throw new RangeError( + `addLogged: vectors.length (${vectors.length}) must equal ids.length (${ids.length}) * dim (${this.dim})` + ) + } + if (ids.length === 0) return + binding.idx_add_logged(ensureHandle(this), vectors, ids, deltaPath) + } + /** * Top-k search across `m` queries packed contiguously in `queries`. * Returns `{ scores, ids, m, k }` where scores/ids are typed arrays of @@ -98,6 +192,79 @@ class IdMapIndex { return binding.idx_search(ensureHandle(this), queries, k) } + /** + * Top-k search restricted to `allowedIds`. Missing ids are ignored; an empty + * allowlist returns only sentinel padding. + * @param {Float32Array} queries - length = m * dim + * @param {number} k + * @param {BigUint64Array} allowedIds + * @returns {{ scores: Float32Array, ids: BigUint64Array, m: number, k: number }} + */ + searchFiltered(queries, k, allowedIds) { + if (!(queries instanceof Float32Array)) { + throw new TypeError('searchFiltered: queries must be a Float32Array') + } + if (!Number.isInteger(k) || k <= 0) { + throw new TypeError('searchFiltered: k must be a positive integer') + } + if (!(allowedIds instanceof BigUint64Array)) { + throw new TypeError('searchFiltered: allowedIds must be a BigUint64Array') + } + return binding.idx_search_filtered(ensureHandle(this), queries, k, allowedIds) + } + + /** + * Prepare an allowlist for repeated filtered searches. Any successful + * mutation on this index invalidates existing prepared filters. + * @param {BigUint64Array} allowedIds + * @returns {IdMapIndexFilter} + */ + prepareFilter(allowedIds) { + if (!(allowedIds instanceof BigUint64Array)) { + throw new TypeError('prepareFilter: allowedIds must be a BigUint64Array') + } + const filter = Object.create(IdMapIndexFilter.prototype) + filter[FILTER_OWNER] = this + filter[FILTER_HANDLE] = binding.idx_filter_create(ensureHandle(this), allowedIds) + return filter + } + + /** + * Build IVF-flat approximate search state. Mutations invalidate this state. + * @param {number} nLists + * @param {number} [nIter=0] + */ + buildIvf(nLists, nIter = 0) { + if (!Number.isInteger(nLists) || nLists <= 0) { + throw new TypeError('buildIvf: nLists must be a positive integer') + } + if (!Number.isInteger(nIter) || nIter < 0) { + throw new TypeError('buildIvf: nIter must be a non-negative integer') + } + binding.idx_build_ivf(ensureHandle(this), nLists, nIter) + } + + /** + * IVF-flat ANN top-k search. `buildIvf()` must have run after the latest + * mutation. + * @param {Float32Array} queries - length = m * dim + * @param {number} k + * @param {number} nProbe + * @returns {{ scores: Float32Array, ids: BigUint64Array, m: number, k: number }} + */ + searchIvf(queries, k, nProbe) { + if (!(queries instanceof Float32Array)) { + throw new TypeError('searchIvf: queries must be a Float32Array') + } + if (!Number.isInteger(k) || k <= 0) { + throw new TypeError('searchIvf: k must be a positive integer') + } + if (!Number.isInteger(nProbe) || nProbe <= 0) { + throw new TypeError('searchIvf: nProbe must be a positive integer') + } + return binding.idx_search_ivf(ensureHandle(this), queries, k, nProbe) + } + /** * Remove an entry by external id. * @param {bigint} id @@ -110,6 +277,27 @@ class IdMapIndex { return binding.idx_remove(ensureHandle(this), id) } + /** + * Remove an entry and append the mutation to an incremental delta log. + * @param {bigint} id + * @param {string} deltaPath + * @returns {boolean} + */ + removeLogged(id, deltaPath) { + if (typeof id !== 'bigint') { + throw new TypeError('removeLogged: id must be a bigint') + } + if (typeof deltaPath !== 'string' || deltaPath.length === 0) { + throw new TypeError('removeLogged: deltaPath must be a non-empty string') + } + return binding.idx_remove_logged(ensureHandle(this), id, deltaPath) + } + + /** Physically remove deleted slots from the in-memory index. */ + compact() { + binding.idx_compact(ensureHandle(this)) + } + /** * @param {bigint} id * @returns {boolean} @@ -137,6 +325,21 @@ class IdMapIndex { binding.idx_write(ensureHandle(this), path) } + /** + * Write a compacted full snapshot and reset the matching delta log. + * @param {string} snapshotPath + * @param {string} deltaPath + */ + compactDelta(snapshotPath, deltaPath) { + if (typeof snapshotPath !== 'string' || snapshotPath.length === 0) { + throw new TypeError('compactDelta: snapshotPath must be a non-empty string') + } + if (typeof deltaPath !== 'string' || deltaPath.length === 0) { + throw new TypeError('compactDelta: deltaPath must be a non-empty string') + } + binding.idx_compact_delta(ensureHandle(this), snapshotPath, deltaPath) + } + get length() { return binding.idx_len(ensureHandle(this)) } @@ -148,18 +351,19 @@ class IdMapIndex { } /** - * Mark the instance as unusable. The native handle is owned by a - * JS-side external whose finalizer runs at GC time; this method only - * drops the local reference so subsequent calls throw (`ensureHandle`). - * No memory is reclaimed synchronously — that requires a finalizer - * pass. Kept as a method for API stability; future versions may add - * a safe early-free path (wrapper object + sentinel) without changing - * the call sites. + * Free native resources and mark the instance as unusable. The external's + * finalizer remains as a safety net if callers forget to dispose. */ dispose() { - this[HANDLE] = null + if (this[HANDLE] !== null && this[HANDLE] !== undefined) { + binding.idx_dispose(this[HANDLE]) + this[HANDLE] = null + } return Promise.resolve() } } +IdMapIndex.Filter = IdMapIndexFilter +IdMapIndex.IdMapIndexFilter = IdMapIndexFilter + module.exports = IdMapIndex diff --git a/packages/embed-llamacpp/index.d.ts b/packages/embed-llamacpp/index.d.ts index 8a71eeafa9..f95632fd83 100644 --- a/packages/embed-llamacpp/index.d.ts +++ b/packages/embed-llamacpp/index.d.ts @@ -95,8 +95,8 @@ export function pickPrimaryGgufPath(files: string[]): string export interface IdMapIndexOptions { /** Vector dimensionality (must be > 0). */ dim: number - /** Storage precision: 8 = q8 quantized storage, 32 = full f32 storage. Defaults to 8. */ - bitWidth?: 8 | 32 + /** Storage precision: 4 = q4, 8 = q8, 32 = full f32 storage. Defaults to 8. */ + bitWidth?: 4 | 8 | 32 } export interface IdMapIndexSearchResult { @@ -110,23 +110,69 @@ export interface IdMapIndexSearchResult { k: number } +export class IdMapIndexFilter { + /** Search with the prepared allowlist. */ + search(queries: Float32Array, k: number): IdMapIndexSearchResult + + dispose(): Promise +} + export class IdMapIndex { constructor(opts: IdMapIndexOptions) /** Open a persisted .tvim file written by `write()`. */ static load(path: string): Promise + /** Open a persisted .tvim file with mmap-backed vector storage. Mutations fail. */ + static loadMmap(path: string): Promise + + /** Open a persisted .tvim snapshot and replay an append-only .tvid delta log. */ + static loadWithDelta( + snapshotPath: string, + deltaPath: string + ): Promise + /** * Insert `n` vectors with stable external ids. Throws on duplicate id or * dim mismatch; mutation is atomic per call. */ addWithIds(vectors: Float32Array, ids: BigUint64Array): void + /** Insert vectors and append the mutation to an incremental .tvid delta log. */ + addLogged( + vectors: Float32Array, + ids: BigUint64Array, + deltaPath: string + ): void + /** Top-k search across `queries.length / dim` rows. */ search(queries: Float32Array, k: number): IdMapIndexSearchResult + /** Top-k search restricted to the supplied allowed ids. */ + searchFiltered( + queries: Float32Array, + k: number, + allowedIds: BigUint64Array + ): IdMapIndexSearchResult + + /** Prepare an allowlist for repeated filtered searches. */ + prepareFilter(allowedIds: BigUint64Array): IdMapIndexFilter + + /** Build IVF-flat approximate search state. Mutations invalidate this state. */ + buildIvf(nLists: number, nIter?: number): void + + /** IVF-flat ANN top-k search. `buildIvf()` must have run after the latest mutation. */ + searchIvf(queries: Float32Array, k: number, nProbe: number): IdMapIndexSearchResult + /** Returns true if removed, false if not present. */ remove(id: bigint): boolean + + /** Remove an entry and append the mutation to an incremental .tvid delta log. */ + removeLogged(id: bigint, deltaPath: string): boolean + + /** Physically remove deleted slots from the in-memory index. */ + compact(): void + contains(id: bigint): boolean /** Placeholder for cache warming / codebook resolution after bulk add. */ @@ -135,9 +181,12 @@ export class IdMapIndex { /** Persist to disk (.tvim v2; legacy v1 files are still readable). */ write(path: string): void + /** Write a compacted full snapshot and reset the matching .tvid delta log. */ + compactDelta(snapshotPath: string, deltaPath: string): void + readonly length: number readonly dim: number - readonly bitWidth: number + readonly bitWidth: 4 | 8 | 32 dispose(): Promise } diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js index 6f7947bfff..637dc6bd0c 100644 --- a/packages/embed-llamacpp/test/integration/id-map-index.test.js +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -47,6 +47,10 @@ function tmpPath(name) { return path.join(os.tmpdir(), `${name}-${pid}-${Date.now()}-${tmpCounter}.tvim`) } +function tmpDeltaPath(name) { + return tmpPath(name).replace(/\.tvim$/, '.tvid') +} + function expectThrows(t, fn, message) { try { fn() @@ -75,6 +79,15 @@ function assertTvimV2Header(t, file, bitWidth) { t.is(bytes[5], bitWidth, 'tvim bit width') } +function assertTvidHeader(t, file) { + const bytes = fs.readFileSync(file) + t.is(bytes[0], 0x54, 'tvid magic T') + t.is(bytes[1], 0x56, 'tvid magic V') + t.is(bytes[2], 0x44, 'tvid magic D') + t.is(bytes[3], 0x4c, 'tvid magic L') + t.is(bytes[4], 1, 'tvid version 1') +} + async function runRoundTrip(t, bitWidth) { const idx = new IdMapIndex({ dim: DIM, bitWidth }) @@ -91,7 +104,7 @@ async function runRoundTrip(t, bitWidth) { t.ok(idx.contains(ids[3]), `contains a known id (${bitWidth})`) t.absent(idx.contains(999n), `absent id missing (${bitWidth})`) - // Unit vectors remain exact for both f32 and q8 storage. + // Unit vectors remain exact for f32, q8, and q4 storage. for (let i = 0; i < N; i++) { const out = idx.search(unitVec(i), 1) t.is(out.m, 1) @@ -132,6 +145,11 @@ async function runRoundTrip(t, bitWidth) { } } + idx.compact() + t.is(idx.length, N - 1, `length preserved after compact (${bitWidth})`) + t.absent(idx.contains(ids[2]), `removed id absent after compact (${bitWidth})`) + t.is(idx.search(unitVec(0), 1).ids[0], ids[0], `search works after compact (${bitWidth})`) + idx.prepare() const file = tmpPath(`id-map-index-roundtrip-${bitWidth}`) @@ -177,6 +195,10 @@ test('IdMapIndex sub-export does not boot the BERT runtime', (t) => { t.absent(require.cache[KEY_ADDON], 'BertInterface plumbing (addon.js) NOT loaded by ./idMapIndex') }) +test('IdMapIndex: q4 add + search + remove + persistence round-trip', async (t) => { + await runRoundTrip(t, 4) +}) + test('IdMapIndex: q8 add + search + remove + persistence round-trip', async (t) => { await runRoundTrip(t, 8) }) @@ -186,7 +208,6 @@ test('IdMapIndex: f32 add + search + remove + persistence round-trip', async (t) }) test('IdMapIndex: validates production bit widths', (t) => { - expectThrows(t, () => new IdMapIndex({ dim: DIM, bitWidth: 4 }), 'bitWidth 4 should be rejected') expectThrows( t, () => new IdMapIndex({ dim: DIM, bitWidth: 16 }), @@ -220,6 +241,195 @@ test('IdMapIndex: BigInt id range edge cases', (t) => { idx.dispose() }) +test('IdMapIndex: filtered search restricts allowed ids', (t) => { + const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) + idx.addWithIds(new Float32Array([1, 0, 0, 1, 0.5, 0.5]), new BigUint64Array([11n, 22n, 33n])) + + { + const out = idx.searchFiltered(new Float32Array([1, 0]), 2, new BigUint64Array([22n, 33n, 44n])) + t.is(out.m, 1, 'filtered m') + t.is(out.k, 2, 'filtered k') + t.is(out.ids[0], 33n, 'best allowed id wins') + t.is(out.ids[1], 22n, 'lower-scoring allowed id follows') + } + + { + const out = idx.searchFiltered(new Float32Array([1, 0]), 2, new BigUint64Array()) + t.is(out.ids[0], UINT64_MAX, 'empty filter returns sentinel id 0') + t.ok(out.scores[0] < -3e38, 'empty filter returns sentinel score 0') + t.is(out.ids[1], UINT64_MAX, 'empty filter returns sentinel id 1') + t.ok(out.scores[1] < -3e38, 'empty filter returns sentinel score 1') + } + + idx.dispose() +}) + +test('IdMapIndex: prepared filters are reusable and invalidated by mutation', async (t) => { + const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) + let filter = null + try { + idx.addWithIds(new Float32Array([1, 0, 0, 1, 0.5, 0.5]), new BigUint64Array([11n, 22n, 33n])) + filter = idx.prepareFilter(new BigUint64Array([22n, 33n])) + + const first = filter.search(new Float32Array([1, 0]), 2) + t.is(first.ids[0], 33n, 'prepared filter best allowed id wins') + t.is(first.ids[1], 22n, 'prepared filter lower allowed id follows') + + const second = filter.search(new Float32Array([0, 1]), 1) + t.is(second.ids[0], 22n, 'prepared filter can be reused') + + idx.addWithIds(new Float32Array([0.25, 0.75]), new BigUint64Array([44n])) + expectThrows( + t, + () => filter.search(new Float32Array([1, 0]), 1), + 'stale prepared filter should throw' + ) + + await filter.dispose() + await filter.dispose() + expectThrows( + t, + () => filter.search(new Float32Array([1, 0]), 1), + 'disposed prepared filter should throw' + ) + filter = null + } finally { + if (filter !== null) await filter.dispose() + await idx.dispose() + } +}) + +test('IdMapIndex: IVF build and search lifecycle', (t) => { + const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) + idx.addWithIds( + new Float32Array([1, 0, 0, 1, 0.5, 0.5, -1, 0]), + new BigUint64Array([11n, 22n, 33n, 44n]) + ) + + expectThrows( + t, + () => idx.searchIvf(new Float32Array([1, 0]), 1, 1), + 'IVF search before build should throw' + ) + + idx.buildIvf(4, 0) + const out = idx.searchIvf(new Float32Array([1, 0]), 2, 4) + t.is(out.m, 1, 'IVF m') + t.is(out.k, 2, 'IVF k') + t.is(out.ids[0], 11n, 'IVF search returns nearest id') + + idx.addWithIds(new Float32Array([0.25, 0.75]), new BigUint64Array([55n])) + expectThrows( + t, + () => idx.searchIvf(new Float32Array([1, 0]), 1, 4), + 'IVF search after mutation should throw' + ) + + idx.buildIvf(4, 1) + t.is(idx.searchIvf(new Float32Array([0, 1]), 1, 4).ids[0], 22n, 'IVF rebuild restores search') + idx.dispose() +}) + +test('IdMapIndex: delta log replay and compaction', async (t) => { + const snapshot = tmpPath('id-map-index-delta-snapshot') + const delta = tmpDeltaPath('id-map-index-delta-log') + let idx = null + let replayed = null + let compacted = null + try { + idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) + idx.addWithIds(new Float32Array([1, 0, 0, 1]), new BigUint64Array([11n, 22n])) + idx.write(snapshot) + + idx.addLogged(new Float32Array([0.5, 0.5]), new BigUint64Array([33n]), delta) + t.ok(idx.removeLogged(11n, delta), 'logged remove returns true for existing id') + t.absent(idx.removeLogged(44n, delta), 'logged remove returns false for absent id') + t.is(idx.length, 2, 'logged mutations update live index') + + replayed = await IdMapIndex.loadWithDelta(snapshot, delta) + t.is(replayed.dim, 2, 'delta replay dim restored') + t.is(replayed.bitWidth, 4, 'delta replay bitWidth restored') + t.absent(replayed.contains(11n), 'delta replay applies remove') + t.ok(replayed.contains(22n), 'delta replay keeps snapshot id') + t.ok(replayed.contains(33n), 'delta replay applies add') + t.is( + replayed.searchFiltered(new Float32Array([0.5, 0.5]), 1, new BigUint64Array([33n])).ids[0], + 33n, + 'delta replay search sees added id' + ) + await replayed.dispose() + replayed = null + + idx.compactDelta(snapshot, delta) + assertTvimV2Header(t, snapshot, 4) + assertTvidHeader(t, delta) + await idx.dispose() + idx = null + + compacted = await IdMapIndex.loadWithDelta(snapshot, delta) + t.absent(compacted.contains(11n), 'compacted snapshot excludes removed id') + t.ok(compacted.contains(22n), 'compacted snapshot keeps existing id') + t.ok(compacted.contains(33n), 'compacted snapshot includes logged add') + } finally { + if (idx !== null) await idx.dispose() + if (replayed !== null) await replayed.dispose() + if (compacted !== null) await compacted.dispose() + if (fs.existsSync(snapshot)) fs.unlinkSync(snapshot) + if (fs.existsSync(delta)) fs.unlinkSync(delta) + } +}) + +test('IdMapIndex: missing delta log replays as empty', async (t) => { + const snapshot = tmpPath('id-map-index-missing-delta-snapshot') + const delta = tmpDeltaPath('id-map-index-missing-delta-log') + let idx = null + let loaded = null + try { + idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) + idx.addWithIds(new Float32Array([1, 0, 0, 1]), new BigUint64Array([11n, 22n])) + idx.write(snapshot) + await idx.dispose() + idx = null + + t.absent(fs.existsSync(delta), 'delta log does not exist before load') + loaded = await IdMapIndex.loadWithDelta(snapshot, delta) + t.is(loaded.dim, 2, 'snapshot dim restored without delta') + t.is(loaded.bitWidth, 4, 'snapshot bitWidth restored without delta') + t.is(loaded.length, 2, 'snapshot length restored without delta') + t.ok(loaded.contains(11n), 'snapshot id 11 restored without delta') + t.ok(loaded.contains(22n), 'snapshot id 22 restored without delta') + } finally { + if (idx !== null) await idx.dispose() + if (loaded !== null) await loaded.dispose() + if (fs.existsSync(snapshot)) fs.unlinkSync(snapshot) + if (fs.existsSync(delta)) fs.unlinkSync(delta) + } +}) + +test('IdMapIndex: corrupt delta log replay fails', async (t) => { + const snapshot = tmpPath('id-map-index-corrupt-delta-snapshot') + const delta = tmpDeltaPath('id-map-index-corrupt-delta-log') + let idx = null + try { + idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) + idx.addWithIds(new Float32Array([1, 0]), new BigUint64Array([11n])) + idx.write(snapshot) + await idx.dispose() + idx = null + + fs.writeFileSync(delta, new Uint8Array([0, 1, 2, 3, 4, 5])) + await expectRejects( + t, + () => IdMapIndex.loadWithDelta(snapshot, delta), + 'corrupt delta log should be rejected' + ) + } finally { + if (idx !== null) await idx.dispose() + if (fs.existsSync(snapshot)) fs.unlinkSync(snapshot) + if (fs.existsSync(delta)) fs.unlinkSync(delta) + } +}) + test('IdMapIndex: rejects non-finite vectors and queries', (t) => { const idx = new IdMapIndex({ dim: 2, bitWidth: 8 }) expectThrows( @@ -240,6 +450,59 @@ test('IdMapIndex: rejects non-finite vectors and queries', (t) => { idx.dispose() }) +test('IdMapIndex: mmap load is searchable and read-only', async (t) => { + const file = tmpPath('id-map-index-mmap') + let mmap = null + let filter = null + try { + const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) + idx.addWithIds(new Float32Array([1, 0, 0, 1]), new BigUint64Array([11n, 22n])) + idx.write(file) + await idx.dispose() + + mmap = await IdMapIndex.loadMmap(file) + t.is(mmap.dim, 2, 'mmap dim restored') + t.is(mmap.bitWidth, 4, 'mmap bitWidth restored') + t.is(mmap.length, 2, 'mmap length restored') + t.ok(mmap.contains(22n), 'mmap contains existing id') + t.is(mmap.search(new Float32Array([0, 1]), 1).ids[0], 22n, 'mmap search works') + t.is( + mmap.searchFiltered(new Float32Array([1, 0]), 1, new BigUint64Array([11n])).ids[0], + 11n, + 'mmap filtered search works' + ) + filter = mmap.prepareFilter(new BigUint64Array([11n])) + t.is( + filter.search(new Float32Array([1, 0]), 1).ids[0], + 11n, + 'mmap prepared filter search works' + ) + + expectThrows( + t, + () => mmap.addWithIds(new Float32Array([0.5, 0.5]), new BigUint64Array([33n])), + 'mmap add should be rejected' + ) + expectThrows(t, () => mmap.remove(11n), 'mmap remove should be rejected') + expectThrows(t, () => mmap.compact(), 'mmap compact should be rejected') + } finally { + if (filter !== null) await filter.dispose() + if (mmap !== null) await mmap.dispose() + if (fs.existsSync(file)) fs.unlinkSync(file) + } +}) + +test('IdMapIndex: dispose is deterministic and idempotent', async (t) => { + const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) + idx.addWithIds(new Float32Array([1, 0]), new BigUint64Array([1n])) + await idx.dispose() + await idx.dispose() + + expectThrows(t, () => idx.contains(1n), 'disposed contains should throw') + expectThrows(t, () => idx.search(new Float32Array([1, 0]), 1), 'disposed search should throw') + expectThrows(t, () => idx.compact(), 'disposed compact should throw') +}) + test('IdMapIndex: corrupt persistence file load fails', async (t) => { const file = tmpPath('id-map-index-corrupt') try { diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake index f6a8cd424c..3ee1e2eb00 100644 --- a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake @@ -22,10 +22,10 @@ # fabric-src-hash: 0000000000000000000000000000000000000000 set(FABRIC_GH_REPO "dev-nid/qvac-fabric-llm.cpp") -set(FABRIC_GH_REF "3a928cf584e0d1b3e98323c422f0d15218bf7b6b") # turbovec +set(FABRIC_GH_REF "8b24b6c72c11ee1aa044068e4f89d150d58e107d") # turbovec set(FABRIC_GH_HEAD_REF "turbovec") set(FABRIC_GH_SHA512 - "a26df5e54ae0cb12ee272d86465221d312949ae520b250412388f4dea4a049dd1598eecaf37a49a22e9a444ca532ebb6bdd70239ddde0d201b3861fdb2e599ee") + "d477c54cf609fafee397b59f614a161b26455f90ce39c9344a14a9b3c01fb0539518ceaf994f3839dd2387c85862258dc355e7b0ceeca680814ada22258abac5") if(DEFINED ENV{QVAC_FABRIC_LOCAL_PATH}) set(FABRIC_LOCAL_PATH "$ENV{QVAC_FABRIC_LOCAL_PATH}") @@ -190,6 +190,7 @@ file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") # even for static builds, so we keep an (empty) bin/ around. Upstream's # tagged release didn't, hence the original removal block. Preserve the # dirs so `find_package(llama)` succeeds. +set(VCPKG_POLICY_ALLOW_EMPTY_FOLDERS enabled) file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/bin") file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/debug/bin") From 406b13c5d4ba8c698ec761179856bedcf59e9ab7 Mon Sep 17 00:00:00 2001 From: Nidhin Date: Thu, 16 Jul 2026 01:26:32 +0530 Subject: [PATCH 04/11] portfile updated to [point to new cpu only branch --- .../embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake index 3ee1e2eb00..2c0500809f 100644 --- a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake @@ -22,10 +22,10 @@ # fabric-src-hash: 0000000000000000000000000000000000000000 set(FABRIC_GH_REPO "dev-nid/qvac-fabric-llm.cpp") -set(FABRIC_GH_REF "8b24b6c72c11ee1aa044068e4f89d150d58e107d") # turbovec -set(FABRIC_GH_HEAD_REF "turbovec") +set(FABRIC_GH_REF "5b49384cb2d6f32a1f19efc919e95a37e478b37d") # turbovec-cpu +set(FABRIC_GH_HEAD_REF "turbovec-cpu") set(FABRIC_GH_SHA512 - "d477c54cf609fafee397b59f614a161b26455f90ce39c9344a14a9b3c01fb0539518ceaf994f3839dd2387c85862258dc355e7b0ceeca680814ada22258abac5") + "b8516eaac76207630f0c15908ffd78e3fe55cf8dbc9316f360402a187e07da2d034df6a5058bc0b965dfb1800209ac0a9d8293f07730861d89ac2d936dfdcc32") if(DEFINED ENV{QVAC_FABRIC_LOCAL_PATH}) set(FABRIC_LOCAL_PATH "$ENV{QVAC_FABRIC_LOCAL_PATH}") From a74d9b45ab6a6c992ca14ca77a4a1582cc0321aa Mon Sep 17 00:00:00 2001 From: Nidhin Date: Thu, 16 Jul 2026 17:54:05 +0530 Subject: [PATCH 05/11] fix[api]: harden IdMapIndex API bindings --- .../src/js-interface/vector-index-binding.cpp | 687 +++++++++--------- .../performance/id-map-index-turbovec-cpu.js | 422 +++++++++++ packages/embed-llamacpp/idMapIndex.d.ts | 104 +++ packages/embed-llamacpp/idMapIndex.js | 79 +- packages/embed-llamacpp/index.d.ts | 108 +-- packages/embed-llamacpp/index.js | 4 +- packages/embed-llamacpp/package.json | 3 +- .../test/integration/id-map-index.test.js | 20 + packages/embed-llamacpp/tsconfig.dts.json | 2 +- .../qvac-fabric/android-vulkan-version.cmake | 26 +- .../vcpkg/ports/qvac-fabric/portfile.cmake | 11 +- 11 files changed, 986 insertions(+), 480 deletions(-) create mode 100644 packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js create mode 100644 packages/embed-llamacpp/idMapIndex.d.ts diff --git a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp index 9ff482c427..ca3090feff 100644 --- a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -11,8 +11,6 @@ // importing the addon does not boot fabric's LLM backend. The same .bare // binary carries both class surfaces; the JS side decides which to construct. -#include - #include #include #include @@ -22,6 +20,8 @@ #include #include +#include + #include "../addon/VectorIndexErrors.hpp" #include "../model-interface/VectorIndex.hpp" @@ -63,21 +63,21 @@ struct VectorIndexFilterExternal { } VectorIndexFilterExternal(const VectorIndexFilterExternal&) = delete; - VectorIndexFilterExternal& operator=(const VectorIndexFilterExternal&) = delete; + VectorIndexFilterExternal& + operator=(const VectorIndexFilterExternal&) = delete; VectorIndexFilter* filter; }; // Finalizer: invoked by the JS engine when the external handle is GC'd. // Tears down the native C handle via VectorIndex's RAII dtor. -void -finalize_vector_index(js_env_t* /*env*/, void* data, void* /*hint*/) { +void finalize_vector_index(js_env_t* /*env*/, void* data, void* /*hint*/) { auto* external = static_cast(data); delete external; } -void -finalize_vector_index_filter(js_env_t* /*env*/, void* data, void* /*hint*/) { +void finalize_vector_index_filter( + js_env_t* /*env*/, void* data, void* /*hint*/) { auto* external = static_cast(data); delete external; } @@ -95,8 +95,8 @@ js_value_t* wrap(js_env_t* env, VectorIndex* idx) { } js_value_t* external = nullptr; - if (js_create_external(env, holder, finalize_vector_index, nullptr, &external) - != 0) { + if (js_create_external( + env, holder, finalize_vector_index, nullptr, &external) != 0) { delete holder; js_throw_error(env, "InternalError", "failed to create external"); return nullptr; @@ -147,9 +147,8 @@ VectorIndex* unwrap(js_env_t* env, js_value_t* handle) { return external->idx; } -VectorIndexFilterExternal* unwrap_filter_external( - js_env_t* env, - js_value_t* handle) { +VectorIndexFilterExternal* +unwrap_filter_external(js_env_t* env, js_value_t* handle) { void* data = nullptr; if (js_get_value_external(env, handle, &data) != 0 || data == nullptr) { js_throw_error(env, "InvalidArgument", "expected IdMapIndexFilter handle"); @@ -164,7 +163,8 @@ VectorIndexFilter* unwrap_filter(js_env_t* env, js_value_t* handle) { return nullptr; } if (external->filter == nullptr) { - js_throw_error(env, "InvalidArgument", "IdMapIndexFilter has been disposed"); + js_throw_error( + env, "InvalidArgument", "IdMapIndexFilter has been disposed"); return nullptr; } return external->filter; @@ -188,11 +188,8 @@ void throw_status(js_env_t* env, int code) { } bool read_utf8_string( - js_env_t* env, - js_value_t* value, - const char* type_error, - const char* read_error, - std::string* out) { + js_env_t* env, js_value_t* value, const char* type_error, + const char* read_error, std::string* out) { size_t len = 0; if (js_get_value_string_utf8(env, value, nullptr, 0, &len) != 0) { js_throw_type_error(env, "InvalidArgument", type_error); @@ -220,17 +217,116 @@ bool read_utf8_string( return true; } +bool read_float32_array( + js_env_t* env, js_value_t* value, const char* name, const float** outData, + size_t* outLen) { + js_typedarray_type_t type{}; + void* data = nullptr; + size_t len = 0; + if (js_get_typedarray_info( + env, value, &type, &data, &len, nullptr, nullptr) != 0 || + type != js_float32array) { + const std::string message = std::string(name) + " must be a Float32Array"; + js_throw_type_error(env, "InvalidArgument", message.c_str()); + return false; + } + + *outData = static_cast(data); + *outLen = len; + return true; +} + +bool read_biguint64_array( + js_env_t* env, js_value_t* value, const char* name, + const uint64_t** outData, size_t* outLen) { + js_typedarray_type_t type{}; + void* data = nullptr; + size_t len = 0; + if (js_get_typedarray_info( + env, value, &type, &data, &len, nullptr, nullptr) != 0 || + type != js_biguint64array) { + const std::string message = std::string(name) + " must be a BigUint64Array"; + js_throw_type_error(env, "InvalidArgument", message.c_str()); + return false; + } + + *outData = static_cast(data); + *outLen = len; + return true; +} + +bool read_positive_int32( + js_env_t* env, js_value_t* value, const char* name, int32_t* out) { + if (js_get_value_int32(env, value, out) != 0 || *out <= 0) { + const std::string message = std::string(name) + " must be a positive int"; + js_throw_type_error(env, "InvalidArgument", message.c_str()); + return false; + } + return true; +} + +bool read_nonnegative_int32( + js_env_t* env, js_value_t* value, const char* name, int32_t* out) { + if (js_get_value_int32(env, value, out) != 0 || *out < 0) { + const std::string message = + std::string(name) + " must be a non-negative int"; + js_throw_type_error(env, "InvalidArgument", message.c_str()); + return false; + } + return true; +} + +struct VectorBatchInput { + const float* vectors = nullptr; + const uint64_t* ids = nullptr; + int n = 0; +}; + +bool read_vector_batch( + js_env_t* env, const VectorIndex* idx, js_value_t* vectorsValue, + js_value_t* idsValue, VectorBatchInput* out) { + const float* vectors = nullptr; + size_t vlen = 0; + if (!read_float32_array(env, vectorsValue, "vectors", &vectors, &vlen)) { + return false; + } + + const uint64_t* ids = nullptr; + size_t ilen = 0; + if (!read_biguint64_array(env, idsValue, "ids", &ids, &ilen)) { + return false; + } + + const int dim = idx->dim(); + if (dim <= 0) { + js_throw_error(env, "InternalError", "index has invalid dim"); + return false; + } + const size_t dim_size = static_cast(dim); + if (ilen > std::numeric_limits::max() / dim_size || + vlen != ilen * dim_size) { + js_throw_range_error( + env, "InvalidArgument", "vectors.length must equal ids.length * dim"); + return false; + } + if (ilen > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many vectors in batch"); + return false; + } + + out->vectors = vectors; + out->ids = ids; + out->n = static_cast(ilen); + return true; +} + bool create_search_output( - js_env_t* env, - const VectorIndex* idx, - size_t qlen, - int32_t k, - int* outM, + js_env_t* env, const VectorIndex* idx, size_t qlen, int32_t k, int* outM, SearchOutput* out) { const int dim = idx->dim(); if (dim <= 0 || qlen % static_cast(dim) != 0) { - js_throw_range_error(env, "InvalidArgument", - "queries.length must be a multiple of dim"); + js_throw_range_error( + env, "InvalidArgument", "queries.length must be a multiple of dim"); return false; } @@ -278,14 +374,37 @@ bool create_search_output( return true; } +struct SearchInput { + const float* queries = nullptr; + int m = 0; + int32_t k = 0; + SearchOutput output; +}; + +bool read_search_input( + js_env_t* env, const VectorIndex* idx, js_value_t* queriesValue, + js_value_t* kValue, SearchInput* out) { + size_t qlen = 0; + if (!read_float32_array(env, queriesValue, "queries", &out->queries, &qlen)) { + return false; + } + + if (!read_positive_int32(env, kValue, "k", &out->k)) { + return false; + } + + return create_search_output(env, idx, qlen, out->k, &out->m, &out->output); +} + js_value_t* finish_search_result( - js_env_t* env, - const SearchOutput& output, - int m, - int32_t k) { + js_env_t* env, const SearchOutput& output, int m, int32_t k) { js_value_t* scores_ta = nullptr; if (js_create_typedarray( - env, js_float32array, output.total, output.scoresBuffer, 0, + env, + js_float32array, + output.total, + output.scoresBuffer, + 0, &scores_ta) != 0) { js_throw_error(env, "InternalError", "create scores typedarray"); return nullptr; @@ -293,8 +412,8 @@ js_value_t* finish_search_result( js_value_t* ids_ta = nullptr; if (js_create_typedarray( - env, js_biguint64array, output.total, output.idsBuffer, 0, - &ids_ta) != 0) { + env, js_biguint64array, output.total, output.idsBuffer, 0, &ids_ta) != + 0) { js_throw_error(env, "InternalError", "create ids typedarray"); return nullptr; } @@ -304,8 +423,8 @@ js_value_t* finish_search_result( js_throw_error(env, "InternalError", "create result object"); return nullptr; } - if (js_set_named_property(env, result, "scores", scores_ta) != 0 - || js_set_named_property(env, result, "ids", ids_ta) != 0) { + if (js_set_named_property(env, result, "scores", scores_ta) != 0 || + js_set_named_property(env, result, "ids", ids_ta) != 0) { js_throw_error(env, "InternalError", "set result fields"); return nullptr; } @@ -326,7 +445,7 @@ js_value_t* finish_search_result( // idx_create({ dim, bitWidth }) -> external handle js_value_t* idx_create(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; - js_value_t* argv[1] = { nullptr }; + js_value_t* argv[1] = {nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } @@ -341,7 +460,7 @@ js_value_t* idx_create(js_env_t* env, js_callback_info_t* info) { return nullptr; } // bitWidth optional; default to 32 if missing. - (void) read_int_prop(env, argv[0], "bitWidth", &bit_width); + (void)read_int_prop(env, argv[0], "bitWidth", &bit_width); VectorIndex* idx = nullptr; try { @@ -359,7 +478,7 @@ js_value_t* idx_create(js_env_t* env, js_callback_info_t* info) { // idx_load(path) -> external handle (throws on file errors). js_value_t* idx_load(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; - js_value_t* argv[1] = { nullptr }; + js_value_t* argv[1] = {nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } @@ -369,7 +488,8 @@ js_value_t* idx_load(js_env_t* env, js_callback_info_t* info) { } std::string path; if (!read_utf8_string( - env, argv[0], + env, + argv[0], "path must be a string", "failed to read path string", &path)) { @@ -394,7 +514,7 @@ js_value_t* idx_load(js_env_t* env, js_callback_info_t* info) { // idx_load_mmap(path) -> external handle (throws on file errors). js_value_t* idx_load_mmap(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; - js_value_t* argv[1] = { nullptr }; + js_value_t* argv[1] = {nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } @@ -404,7 +524,8 @@ js_value_t* idx_load_mmap(js_env_t* env, js_callback_info_t* info) { } std::string path; if (!read_utf8_string( - env, argv[0], + env, + argv[0], "path must be a string", "failed to read path string", &path)) { @@ -429,19 +550,20 @@ js_value_t* idx_load_mmap(js_env_t* env, js_callback_info_t* info) { // idx_load_with_delta(snapshotPath, deltaPath) -> external handle. js_value_t* idx_load_with_delta(js_env_t* env, js_callback_info_t* info) { size_t argc = 2; - js_value_t* argv[2] = { nullptr, nullptr }; + js_value_t* argv[2] = {nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 2) { - js_throw_type_error(env, "InvalidArgument", - "expected (snapshotPath, deltaPath)"); + js_throw_type_error( + env, "InvalidArgument", "expected (snapshotPath, deltaPath)"); return nullptr; } std::string snapshot_path; if (!read_utf8_string( - env, argv[0], + env, + argv[0], "snapshotPath must be a string", "failed to read snapshot path string", &snapshot_path)) { @@ -449,7 +571,8 @@ js_value_t* idx_load_with_delta(js_env_t* env, js_callback_info_t* info) { } std::string delta_path; if (!read_utf8_string( - env, argv[1], + env, + argv[1], "deltaPath must be a string", "failed to read delta path string", &delta_path)) { @@ -458,8 +581,8 @@ js_value_t* idx_load_with_delta(js_env_t* env, js_callback_info_t* info) { VectorIndex loaded = VectorIndex::loadWithDelta(snapshot_path, delta_path); if (!loaded.valid()) { - js_throw_error(env, "IOError", - "ggml_vec_index_load_with_delta returned null"); + js_throw_error( + env, "IOError", "ggml_vec_index_load_with_delta returned null"); return nullptr; } try { @@ -474,61 +597,28 @@ js_value_t* idx_load_with_delta(js_env_t* env, js_callback_info_t* info) { // idx_add(handle, Float32Array vectors, BigUint64Array ids) -> undefined js_value_t* idx_add(js_env_t* env, js_callback_info_t* info) { size_t argc = 3; - js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + js_value_t* argv[3] = {nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 3) { - js_throw_type_error(env, "InvalidArgument", + js_throw_type_error( + env, + "InvalidArgument", "expected (handle, vectors:Float32Array, ids:BigUint64Array)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } - - js_typedarray_type_t vtype{}; - void* vdata = nullptr; - size_t vlen = 0; - if (js_get_typedarray_info( - env, argv[1], &vtype, &vdata, &vlen, nullptr, nullptr) != 0 - || vtype != js_float32array) { - js_throw_type_error(env, "InvalidArgument", - "vectors must be a Float32Array"); + if (idx == nullptr) { return nullptr; } - js_typedarray_type_t itype{}; - void* idata = nullptr; - size_t ilen = 0; - if (js_get_typedarray_info( - env, argv[2], &itype, &idata, &ilen, nullptr, nullptr) != 0 - || itype != js_biguint64array) { - js_throw_type_error(env, "InvalidArgument", - "ids must be a BigUint64Array"); + VectorBatchInput batch; + if (!read_vector_batch(env, idx, argv[1], argv[2], &batch)) { return nullptr; } - const int dim = idx->dim(); - if (dim <= 0) { - js_throw_error(env, "InternalError", "index has invalid dim"); - return nullptr; - } - const size_t dim_size = static_cast(dim); - if (ilen > std::numeric_limits::max() / dim_size - || vlen != ilen * dim_size) { - js_throw_range_error(env, "InvalidArgument", - "vectors.length must equal ids.length * dim"); - return nullptr; - } - if (ilen > static_cast(INT32_MAX)) { - js_throw_range_error(env, "InvalidArgument", "too many vectors in batch"); - return nullptr; - } - - const int rc = idx->add( - static_cast(vdata), - static_cast(ilen), - static_cast(idata)); + const int rc = idx->add(batch.vectors, batch.n, batch.ids); if (rc != 0) { throw_status(env, rc); return nullptr; @@ -542,71 +632,39 @@ js_value_t* idx_add(js_env_t* env, js_callback_info_t* info) { // -> undefined js_value_t* idx_add_logged(js_env_t* env, js_callback_info_t* info) { size_t argc = 4; - js_value_t* argv[4] = { nullptr, nullptr, nullptr, nullptr }; + js_value_t* argv[4] = {nullptr, nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 4) { - js_throw_type_error(env, "InvalidArgument", - "expected (handle, vectors:Float32Array, ids:BigUint64Array, deltaPath)"); + js_throw_type_error( + env, + "InvalidArgument", + "expected (handle, vectors:Float32Array, ids:BigUint64Array, " + "deltaPath)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } - - js_typedarray_type_t vtype{}; - void* vdata = nullptr; - size_t vlen = 0; - if (js_get_typedarray_info( - env, argv[1], &vtype, &vdata, &vlen, nullptr, nullptr) != 0 - || vtype != js_float32array) { - js_throw_type_error(env, "InvalidArgument", - "vectors must be a Float32Array"); + if (idx == nullptr) { return nullptr; } - js_typedarray_type_t itype{}; - void* idata = nullptr; - size_t ilen = 0; - if (js_get_typedarray_info( - env, argv[2], &itype, &idata, &ilen, nullptr, nullptr) != 0 - || itype != js_biguint64array) { - js_throw_type_error(env, "InvalidArgument", - "ids must be a BigUint64Array"); + VectorBatchInput batch; + if (!read_vector_batch(env, idx, argv[1], argv[2], &batch)) { return nullptr; } std::string delta_path; if (!read_utf8_string( - env, argv[3], + env, + argv[3], "deltaPath must be a string", "failed to read delta path", &delta_path)) { return nullptr; } - const int dim = idx->dim(); - if (dim <= 0) { - js_throw_error(env, "InternalError", "index has invalid dim"); - return nullptr; - } - const size_t dim_size = static_cast(dim); - if (ilen > std::numeric_limits::max() / dim_size - || vlen != ilen * dim_size) { - js_throw_range_error(env, "InvalidArgument", - "vectors.length must equal ids.length * dim"); - return nullptr; - } - if (ilen > static_cast(INT32_MAX)) { - js_throw_range_error(env, "InvalidArgument", "too many vectors in batch"); - return nullptr; - } - - const int rc = idx->addLogged( - static_cast(vdata), - static_cast(ilen), - static_cast(idata), - delta_path); + const int rc = idx->addLogged(batch.vectors, batch.n, batch.ids, delta_path); if (rc != 0) { throw_status(env, rc); return nullptr; @@ -620,96 +678,70 @@ js_value_t* idx_add_logged(js_env_t* env, js_callback_info_t* info) { // -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } js_value_t* idx_search(js_env_t* env, js_callback_info_t* info) { size_t argc = 3; - js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + js_value_t* argv[3] = {nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 3) { - js_throw_type_error(env, "InvalidArgument", + js_throw_type_error( + env, + "InvalidArgument", "expected (handle, queries:Float32Array, k:number)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } - - js_typedarray_type_t qtype{}; - void* qdata = nullptr; - size_t qlen = 0; - if (js_get_typedarray_info( - env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 - || qtype != js_float32array) { - js_throw_type_error(env, "InvalidArgument", - "queries must be a Float32Array"); + if (idx == nullptr) { return nullptr; } - int32_t k = 0; - if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { - js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); - return nullptr; - } - - int m = 0; - SearchOutput output; - if (!create_search_output(env, idx, qlen, k, &m, &output)) { + SearchInput input; + if (!read_search_input(env, idx, argv[1], argv[2], &input)) { return nullptr; } const int rc = idx->search( - static_cast(qdata), - m, - k, - static_cast(output.scoresData), - static_cast(output.idsData)); + input.queries, + input.m, + input.k, + static_cast(input.output.scoresData), + static_cast(input.output.idsData)); if (rc != 0) { throw_status(env, rc); return nullptr; } - return finish_search_result(env, output, m, k); + return finish_search_result(env, input.output, input.m, input.k); } // idx_search_filtered(handle, Float32Array queries, int k, BigUint64Array ids) // -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } js_value_t* idx_search_filtered(js_env_t* env, js_callback_info_t* info) { size_t argc = 4; - js_value_t* argv[4] = { nullptr, nullptr, nullptr, nullptr }; + js_value_t* argv[4] = {nullptr, nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 4) { - js_throw_type_error(env, "InvalidArgument", - "expected (handle, queries:Float32Array, k:number, allowedIds:BigUint64Array)"); + js_throw_type_error( + env, + "InvalidArgument", + "expected (handle, queries:Float32Array, k:number, " + "allowedIds:BigUint64Array)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } - - js_typedarray_type_t qtype{}; - void* qdata = nullptr; - size_t qlen = 0; - if (js_get_typedarray_info( - env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 - || qtype != js_float32array) { - js_throw_type_error(env, "InvalidArgument", - "queries must be a Float32Array"); + if (idx == nullptr) { return nullptr; } - int32_t k = 0; - if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { - js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + SearchInput input; + if (!read_search_input(env, idx, argv[1], argv[2], &input)) { return nullptr; } - js_typedarray_type_t atype{}; - void* adata = nullptr; + const uint64_t* allowed_ids = nullptr; size_t alen = 0; - if (js_get_typedarray_info( - env, argv[3], &atype, &adata, &alen, nullptr, nullptr) != 0 - || atype != js_biguint64array) { - js_throw_type_error(env, "InvalidArgument", - "allowedIds must be a BigUint64Array"); + if (!read_biguint64_array(env, argv[3], "allowedIds", &allowed_ids, &alen)) { return nullptr; } if (alen > static_cast(INT32_MAX)) { @@ -717,51 +749,42 @@ js_value_t* idx_search_filtered(js_env_t* env, js_callback_info_t* info) { return nullptr; } - int m = 0; - SearchOutput output; - if (!create_search_output(env, idx, qlen, k, &m, &output)) { - return nullptr; - } - const int rc = idx->searchFiltered( - static_cast(qdata), - m, - k, - alen == 0 ? nullptr : static_cast(adata), + input.queries, + input.m, + input.k, + alen == 0 ? nullptr : allowed_ids, static_cast(alen), - static_cast(output.scoresData), - static_cast(output.idsData)); + static_cast(input.output.scoresData), + static_cast(input.output.idsData)); if (rc != 0) { throw_status(env, rc); return nullptr; } - return finish_search_result(env, output, m, k); + return finish_search_result(env, input.output, input.m, input.k); } // idx_filter_create(handle, BigUint64Array allowedIds) -> external filter js_value_t* idx_filter_create(js_env_t* env, js_callback_info_t* info) { size_t argc = 2; - js_value_t* argv[2] = { nullptr, nullptr }; + js_value_t* argv[2] = {nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 2) { - js_throw_type_error(env, "InvalidArgument", - "expected (handle, allowedIds:BigUint64Array)"); + js_throw_type_error( + env, "InvalidArgument", "expected (handle, allowedIds:BigUint64Array)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } - js_typedarray_type_t atype{}; - void* adata = nullptr; + const uint64_t* allowed_ids = nullptr; size_t alen = 0; - if (js_get_typedarray_info( - env, argv[1], &atype, &adata, &alen, nullptr, nullptr) != 0 - || atype != js_biguint64array) { - js_throw_type_error(env, "InvalidArgument", - "allowedIds must be a BigUint64Array"); + if (!read_biguint64_array(env, argv[1], "allowedIds", &allowed_ids, &alen)) { return nullptr; } if (alen > static_cast(INT32_MAX)) { @@ -770,11 +793,10 @@ js_value_t* idx_filter_create(js_env_t* env, js_callback_info_t* info) { } VectorIndexFilter filter = idx->createFilter( - alen == 0 ? nullptr : static_cast(adata), - static_cast(alen)); + alen == 0 ? nullptr : allowed_ids, static_cast(alen)); if (!filter.valid()) { - js_throw_error(env, "InvalidArgument", - "ggml_vec_index_filter_create returned null"); + js_throw_error( + env, "InvalidArgument", "ggml_vec_index_filter_create returned null"); return nullptr; } @@ -789,88 +811,75 @@ js_value_t* idx_filter_create(js_env_t* env, js_callback_info_t* info) { // idx_search_prepared_filtered(handle, filter, Float32Array queries, int k) // -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } -js_value_t* idx_search_prepared_filtered( - js_env_t* env, - js_callback_info_t* info) { +js_value_t* +idx_search_prepared_filtered(js_env_t* env, js_callback_info_t* info) { size_t argc = 4; - js_value_t* argv[4] = { nullptr, nullptr, nullptr, nullptr }; + js_value_t* argv[4] = {nullptr, nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 4) { - js_throw_type_error(env, "InvalidArgument", + js_throw_type_error( + env, + "InvalidArgument", "expected (handle, filter, queries:Float32Array, k:number)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } - VectorIndexFilter* filter = unwrap_filter(env, argv[1]); - if (filter == nullptr) { return nullptr; } - - js_typedarray_type_t qtype{}; - void* qdata = nullptr; - size_t qlen = 0; - if (js_get_typedarray_info( - env, argv[2], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 - || qtype != js_float32array) { - js_throw_type_error(env, "InvalidArgument", - "queries must be a Float32Array"); + if (idx == nullptr) { return nullptr; } - - int32_t k = 0; - if (js_get_value_int32(env, argv[3], &k) != 0 || k <= 0) { - js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + VectorIndexFilter* filter = unwrap_filter(env, argv[1]); + if (filter == nullptr) { return nullptr; } - int m = 0; - SearchOutput output; - if (!create_search_output(env, idx, qlen, k, &m, &output)) { + SearchInput input; + if (!read_search_input(env, idx, argv[2], argv[3], &input)) { return nullptr; } const int rc = idx->searchPreparedFiltered( *filter, - static_cast(qdata), - m, - k, - static_cast(output.scoresData), - static_cast(output.idsData)); + input.queries, + input.m, + input.k, + static_cast(input.output.scoresData), + static_cast(input.output.idsData)); if (rc != 0) { throw_status(env, rc); return nullptr; } - return finish_search_result(env, output, m, k); + return finish_search_result(env, input.output, input.m, input.k); } // idx_build_ivf(handle, nLists:number, nIter:number) -> undefined js_value_t* idx_build_ivf(js_env_t* env, js_callback_info_t* info) { size_t argc = 3; - js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + js_value_t* argv[3] = {nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 3) { - js_throw_type_error(env, "InvalidArgument", + js_throw_type_error( + env, + "InvalidArgument", "expected (handle, nLists:number, nIter:number)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } int32_t n_lists = 0; - if (js_get_value_int32(env, argv[1], &n_lists) != 0 || n_lists <= 0) { - js_throw_type_error(env, "InvalidArgument", - "nLists must be a positive int"); + if (!read_positive_int32(env, argv[1], "nLists", &n_lists)) { return nullptr; } int32_t n_iter = 0; - if (js_get_value_int32(env, argv[2], &n_iter) != 0 || n_iter < 0) { - js_throw_type_error(env, "InvalidArgument", - "nIter must be a non-negative int"); + if (!read_nonnegative_int32(env, argv[2], "nIter", &n_iter)) { return nullptr; } @@ -889,83 +898,70 @@ js_value_t* idx_build_ivf(js_env_t* env, js_callback_info_t* info) { // -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } js_value_t* idx_search_ivf(js_env_t* env, js_callback_info_t* info) { size_t argc = 4; - js_value_t* argv[4] = { nullptr, nullptr, nullptr, nullptr }; + js_value_t* argv[4] = {nullptr, nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 4) { - js_throw_type_error(env, "InvalidArgument", + js_throw_type_error( + env, + "InvalidArgument", "expected (handle, queries:Float32Array, k:number, nProbe:number)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } - - js_typedarray_type_t qtype{}; - void* qdata = nullptr; - size_t qlen = 0; - if (js_get_typedarray_info( - env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 - || qtype != js_float32array) { - js_throw_type_error(env, "InvalidArgument", - "queries must be a Float32Array"); + if (idx == nullptr) { return nullptr; } - int32_t k = 0; - if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { - js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + SearchInput input; + if (!read_search_input(env, idx, argv[1], argv[2], &input)) { return nullptr; } int32_t n_probe = 0; - if (js_get_value_int32(env, argv[3], &n_probe) != 0 || n_probe <= 0) { - js_throw_type_error(env, "InvalidArgument", - "nProbe must be a positive int"); - return nullptr; - } - - int m = 0; - SearchOutput output; - if (!create_search_output(env, idx, qlen, k, &m, &output)) { + if (!read_positive_int32(env, argv[3], "nProbe", &n_probe)) { return nullptr; } const int rc = idx->searchIvf( - static_cast(qdata), - m, - k, + input.queries, + input.m, + input.k, n_probe, - static_cast(output.scoresData), - static_cast(output.idsData)); + static_cast(input.output.scoresData), + static_cast(input.output.idsData)); if (rc != 0) { throw_status(env, rc); return nullptr; } - return finish_search_result(env, output, m, k); + return finish_search_result(env, input.output, input.m, input.k); } // idx_remove(handle, id:bigint) -> boolean js_value_t* idx_remove(js_env_t* env, js_callback_info_t* info) { size_t argc = 2; - js_value_t* argv[2] = { nullptr, nullptr }; + js_value_t* argv[2] = {nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 2) { - js_throw_type_error(env, "InvalidArgument", - "expected (handle, id:bigint)"); + js_throw_type_error(env, "InvalidArgument", "expected (handle, id:bigint)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } uint64_t id = 0; bool lossless = false; - if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 - || !lossless) { - js_throw_type_error(env, "InvalidArgument", + if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 || + !lossless) { + js_throw_type_error( + env, + "InvalidArgument", "id must be an unsigned BigInt fitting in 64 bits"); return nullptr; } @@ -983,30 +979,35 @@ js_value_t* idx_remove(js_env_t* env, js_callback_info_t* info) { // idx_remove_logged(handle, id:bigint, deltaPath) -> boolean js_value_t* idx_remove_logged(js_env_t* env, js_callback_info_t* info) { size_t argc = 3; - js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + js_value_t* argv[3] = {nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 3) { - js_throw_type_error(env, "InvalidArgument", - "expected (handle, id:bigint, deltaPath)"); + js_throw_type_error( + env, "InvalidArgument", "expected (handle, id:bigint, deltaPath)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } uint64_t id = 0; bool lossless = false; - if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 - || !lossless) { - js_throw_type_error(env, "InvalidArgument", + if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 || + !lossless) { + js_throw_type_error( + env, + "InvalidArgument", "id must be an unsigned BigInt fitting in 64 bits"); return nullptr; } std::string delta_path; if (!read_utf8_string( - env, argv[2], + env, + argv[2], "deltaPath must be a string", "failed to read delta path", &delta_path)) { @@ -1026,12 +1027,14 @@ js_value_t* idx_remove_logged(js_env_t* env, js_callback_info_t* info) { // idx_compact(handle) -> undefined js_value_t* idx_compact(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; - js_value_t* argv[1] = { nullptr }; + js_value_t* argv[1] = {nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } const int rc = idx->compact(); if (rc != 0) { @@ -1046,23 +1049,26 @@ js_value_t* idx_compact(js_env_t* env, js_callback_info_t* info) { // idx_contains(handle, id:bigint) -> boolean js_value_t* idx_contains(js_env_t* env, js_callback_info_t* info) { size_t argc = 2; - js_value_t* argv[2] = { nullptr, nullptr }; + js_value_t* argv[2] = {nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 2) { - js_throw_type_error(env, "InvalidArgument", - "expected (handle, id:bigint)"); + js_throw_type_error(env, "InvalidArgument", "expected (handle, id:bigint)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } uint64_t id = 0; bool lossless = false; - if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 - || !lossless) { - js_throw_type_error(env, "InvalidArgument", + if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 || + !lossless) { + js_throw_type_error( + env, + "InvalidArgument", "id must be an unsigned BigInt fitting in 64 bits"); return nullptr; } @@ -1074,12 +1080,14 @@ js_value_t* idx_contains(js_env_t* env, js_callback_info_t* info) { // idx_prepare(handle) -> undefined (currently a no-op). js_value_t* idx_prepare(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; - js_value_t* argv[1] = { nullptr }; + js_value_t* argv[1] = {nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } idx->prepare(); js_value_t* u = nullptr; js_get_undefined(env, &u); @@ -1089,7 +1097,7 @@ js_value_t* idx_prepare(js_env_t* env, js_callback_info_t* info) { // idx_write(handle, path) -> undefined; throws on IO error. js_value_t* idx_write(js_env_t* env, js_callback_info_t* info) { size_t argc = 2; - js_value_t* argv[2] = { nullptr, nullptr }; + js_value_t* argv[2] = {nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } @@ -1098,11 +1106,14 @@ js_value_t* idx_write(js_env_t* env, js_callback_info_t* info) { return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } std::string path; if (!read_utf8_string( - env, argv[1], + env, + argv[1], "path must be a string", "failed to read path", &path)) { @@ -1122,21 +1133,24 @@ js_value_t* idx_write(js_env_t* env, js_callback_info_t* info) { // idx_compact_delta(handle, snapshotPath, deltaPath) -> undefined. js_value_t* idx_compact_delta(js_env_t* env, js_callback_info_t* info) { size_t argc = 3; - js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + js_value_t* argv[3] = {nullptr, nullptr, nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } if (argc < 3) { - js_throw_type_error(env, "InvalidArgument", - "expected (handle, snapshotPath, deltaPath)"); + js_throw_type_error( + env, "InvalidArgument", "expected (handle, snapshotPath, deltaPath)"); return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } std::string snapshot_path; if (!read_utf8_string( - env, argv[1], + env, + argv[1], "snapshotPath must be a string", "failed to read snapshot path", &snapshot_path)) { @@ -1144,7 +1158,8 @@ js_value_t* idx_compact_delta(js_env_t* env, js_callback_info_t* info) { } std::string delta_path; if (!read_utf8_string( - env, argv[2], + env, + argv[2], "deltaPath must be a string", "failed to read delta path", &delta_path)) { @@ -1166,7 +1181,7 @@ js_value_t* idx_compact_delta(js_env_t* env, js_callback_info_t* info) { // JS external finalizer remains safe and becomes a no-op for the index. js_value_t* idx_dispose(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; - js_value_t* argv[1] = { nullptr }; + js_value_t* argv[1] = {nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } @@ -1175,7 +1190,9 @@ js_value_t* idx_dispose(js_env_t* env, js_callback_info_t* info) { return nullptr; } VectorIndexExternal* external = unwrap_external(env, argv[0]); - if (external == nullptr) { return nullptr; } + if (external == nullptr) { + return nullptr; + } delete external->idx; external->idx = nullptr; @@ -1188,7 +1205,7 @@ js_value_t* idx_dispose(js_env_t* env, js_callback_info_t* info) { // idx_filter_dispose(filter) -> undefined. js_value_t* idx_filter_dispose(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; - js_value_t* argv[1] = { nullptr }; + js_value_t* argv[1] = {nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } @@ -1197,7 +1214,9 @@ js_value_t* idx_filter_dispose(js_env_t* env, js_callback_info_t* info) { return nullptr; } VectorIndexFilterExternal* external = unwrap_filter_external(env, argv[0]); - if (external == nullptr) { return nullptr; } + if (external == nullptr) { + return nullptr; + } delete external->filter; external->filter = nullptr; @@ -1211,12 +1230,14 @@ js_value_t* idx_filter_dispose(js_env_t* env, js_callback_info_t* info) { template js_value_t* idx_int_getter(js_env_t* env, js_callback_info_t* info) { size_t argc = 1; - js_value_t* argv[1] = { nullptr }; + js_value_t* argv[1] = {nullptr}; if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { return nullptr; } VectorIndex* idx = unwrap(env, argv[0]); - if (idx == nullptr) { return nullptr; } + if (idx == nullptr) { + return nullptr; + } js_value_t* result = nullptr; js_create_int32(env, (idx->*Fn)(), &result); return result; @@ -1239,32 +1260,32 @@ void registerBindings(js_env_t* env, js_value_t* exports) { } \ } while (0) - V("idx_create", idx_create); - V("idx_load", idx_load); + V("idx_create", idx_create); + V("idx_load", idx_load); V("idx_load_mmap", idx_load_mmap); V("idx_load_with_delta", idx_load_with_delta); - V("idx_add", idx_add); + V("idx_add", idx_add); V("idx_add_logged", idx_add_logged); - V("idx_search", idx_search); + V("idx_search", idx_search); V("idx_search_filtered", idx_search_filtered); V("idx_filter_create", idx_filter_create); V("idx_search_prepared_filtered", idx_search_prepared_filtered); V("idx_build_ivf", idx_build_ivf); V("idx_search_ivf", idx_search_ivf); - V("idx_remove", idx_remove); + V("idx_remove", idx_remove); V("idx_remove_logged", idx_remove_logged); - V("idx_compact", idx_compact); - V("idx_contains", idx_contains); - V("idx_prepare", idx_prepare); - V("idx_write", idx_write); + V("idx_compact", idx_compact); + V("idx_contains", idx_contains); + V("idx_prepare", idx_prepare); + V("idx_write", idx_write); V("idx_compact_delta", idx_compact_delta); - V("idx_dispose", idx_dispose); + V("idx_dispose", idx_dispose); V("idx_filter_dispose", idx_filter_dispose); - V("idx_len", (idx_int_getter<&VectorIndex::len>)); - V("idx_dim", (idx_int_getter<&VectorIndex::dim>)); + V("idx_len", (idx_int_getter<&VectorIndex::len>)); + V("idx_dim", (idx_int_getter<&VectorIndex::dim>)); V("idx_bit_width", (idx_int_getter<&VectorIndex::bitWidth>)); #undef V -// NOLINTEND(cppcoreguidelines-macro-usage) + // NOLINTEND(cppcoreguidelines-macro-usage) } } // namespace qvac_lib_inference_addon_embed::vector_index diff --git a/packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js b/packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js new file mode 100644 index 0000000000..b970972b69 --- /dev/null +++ b/packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js @@ -0,0 +1,422 @@ +'use strict' + +const fs = require('bare-fs') +const path = require('bare-path') +const process = require('bare-process') +const IdMapIndex = require('../../idMapIndex') + +const DIM = 384 +const VECTOR_COUNT = 10000 +const QUERY_COUNT = 128 +const K = 10 +const BIT_WIDTHS = [4, 8, 32] +const FILTER_COUNT = 1000 +const IVF_LISTS = 100 +const IVF_NPROBE = 10 +const DELTA_COUNT = 128 +const MEASURED_SEARCH_RUNS = 3 +const REPORT_PATH = path.join(__dirname, 'id-map-index-turbovec-cpu-report.md') +const TMP_DIR = path.join(__dirname, '.tmp-id-map-index-turbovec') +const MAIN_BASELINE_COMMIT = '168e2dd15' +const MAIN_BUILD_STATUS = + 'built successfully with npm install, bare-make generate, bare-make build, and bare-make install' + +function createRng(seed) { + let state = seed >>> 0 + return function next() { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +function createNormalizedVectors(count, dim, seed) { + const rng = createRng(seed) + const values = new Float32Array(count * dim) + for (let row = 0; row < count; row++) { + let norm = 0 + const offset = row * dim + for (let col = 0; col < dim; col++) { + const value = rng() * 2 - 1 + values[offset + col] = value + norm += value * value + } + norm = Math.sqrt(norm) || 1 + for (let col = 0; col < dim; col++) { + values[offset + col] /= norm + } + } + return values +} + +function createIds(count, start) { + const ids = new BigUint64Array(count) + for (let i = 0; i < count; i++) { + ids[i] = BigInt(start + i) + } + return ids +} + +function createQueries(vectors, vectorCount, dim, queryCount) { + const queries = new Float32Array(queryCount * dim) + for (let row = 0; row < queryCount; row++) { + const source = (row * 7919) % vectorCount + queries.set(vectors.subarray(source * dim, source * dim + dim), row * dim) + } + return queries +} + +function createFilterIds(ids, count) { + const filterIds = new BigUint64Array(count) + const step = Math.max(1, Math.floor(ids.length / count)) + for (let i = 0; i < count; i++) { + filterIds[i] = ids[(i * step) % ids.length] + } + return filterIds +} + +function nowMs() { + return Number(process.hrtime.bigint()) / 1e6 +} + +function measureSync(fn) { + const start = nowMs() + const value = fn() + return { ms: nowMs() - start, value } +} + +async function measureAsync(fn) { + const start = nowMs() + const value = await fn() + return { ms: nowMs() - start, value } +} + +function median(values) { + const sorted = values.slice().sort((a, b) => a - b) + return sorted[Math.floor(sorted.length / 2)] +} + +function measureMedianMs(fn) { + const samples = [] + for (let i = 0; i < MEASURED_SEARCH_RUNS; i++) { + samples.push(measureSync(fn).ms) + } + return median(samples) +} + +function qps(count, ms) { + return ms === 0 ? 0 : (count * 1000) / ms +} + +function round(value, digits = 3) { + const scale = 10 ** digits + return Math.round(value * scale) / scale +} + +function mb(bytes) { + return bytes / 1024 / 1024 +} + +function kb(bytes) { + return bytes / 1024 +} + +function fileSize(file) { + return fs.statSync(file).size +} + +function cleanupFile(file) { + if (fs.existsSync(file)) fs.unlinkSync(file) +} + +function ensureTmpDir() { + fs.mkdirSync(TMP_DIR, { recursive: true }) +} + +function recallAtK(exactIds, approxIds, queryCount, k) { + let hits = 0 + for (let row = 0; row < queryCount; row++) { + const exact = new Set() + for (let slot = 0; slot < k; slot++) { + exact.add(exactIds[row * k + slot].toString()) + } + for (let slot = 0; slot < k; slot++) { + if (exact.has(approxIds[row * k + slot].toString())) hits++ + } + } + return hits / (queryCount * k) +} + +function bruteForceSearch(vectors, ids, queries, nVectors, dim, nQueries, k) { + const outIds = new BigUint64Array(nQueries * k) + const outScores = new Float32Array(nQueries * k) + for (let row = 0; row < nQueries; row++) { + const bestScores = new Float32Array(k) + const bestIds = new BigUint64Array(k) + for (let slot = 0; slot < k; slot++) { + bestScores[slot] = -Infinity + bestIds[slot] = (1n << 64n) - 1n + } + + const queryOffset = row * dim + for (let vectorIndex = 0; vectorIndex < nVectors; vectorIndex++) { + const vectorOffset = vectorIndex * dim + let score = 0 + for (let col = 0; col < dim; col++) { + score += queries[queryOffset + col] * vectors[vectorOffset + col] + } + for (let slot = 0; slot < k; slot++) { + if (score <= bestScores[slot]) continue + for (let shift = k - 1; shift > slot; shift--) { + bestScores[shift] = bestScores[shift - 1] + bestIds[shift] = bestIds[shift - 1] + } + bestScores[slot] = score + bestIds[slot] = ids[vectorIndex] + break + } + } + + outScores.set(bestScores, row * k) + outIds.set(bestIds, row * k) + } + return { ids: outIds, scores: outScores } +} + +async function runCase(bitWidth, vectors, ids, queries, filterIds, deltaVectors) { + const storage = bitWidth === 32 ? 'f32' : `q${bitWidth}` + const snapshot = path.join(TMP_DIR, `id-map-index-${storage}.tvim`) + const delta = path.join(TMP_DIR, `id-map-index-${storage}.tvid`) + cleanupFile(snapshot) + cleanupFile(delta) + + let idx = null + let loaded = null + let mmap = null + let filter = null + try { + idx = new IdMapIndex({ dim: DIM, bitWidth }) + + const add = measureSync(() => { + idx.addWithIds(vectors, ids) + }) + + idx.search(queries.subarray(0, DIM * 8), K) + const exactResult = idx.search(queries, K) + const exactMs = measureMedianMs(() => { + idx.search(queries, K) + }) + + const filterBuild = measureSync(() => { + filter = idx.prepareFilter(filterIds) + }) + filter.search(queries.subarray(0, DIM * 8), K) + const preparedFilterMs = measureMedianMs(() => { + filter.search(queries, K) + }) + + const ivfBuild = measureSync(() => { + idx.buildIvf(IVF_LISTS, 1) + }) + idx.searchIvf(queries.subarray(0, DIM * 8), K, IVF_NPROBE) + const ivfResult = idx.searchIvf(queries, K, IVF_NPROBE) + const ivfMs = measureMedianMs(() => { + idx.searchIvf(queries, K, IVF_NPROBE) + }) + + measureSync(() => { + idx.write(snapshot) + }) + const sizeBytes = fileSize(snapshot) + + const load = await measureAsync(() => IdMapIndex.load(snapshot)) + loaded = load.value + const mmapLoad = await measureAsync(() => IdMapIndex.loadMmap(snapshot)) + mmap = mmapLoad.value + mmap.search(queries.subarray(0, DIM * 8), K) + const mmapExactMs = measureMedianMs(() => { + mmap.search(queries, K) + }) + + const deltaIds = createIds(DELTA_COUNT, 9000000 + bitWidth * 1000) + const addLogged = measureSync(() => { + idx.addLogged(deltaVectors, deltaIds, delta) + }) + const removeLogged = measureSync(() => { + for (let i = 0; i < DELTA_COUNT; i++) { + idx.removeLogged(ids[i], delta) + } + }) + + return { + storage, + fileMb: round(mb(sizeBytes), 2), + ingestVectorsPerSec: Math.round(qps(VECTOR_COUNT, add.ms)), + exactQps: Math.round(qps(QUERY_COUNT, exactMs)), + exactMsPerQuery: round(exactMs / QUERY_COUNT, 3), + ivfBuildMs: round(ivfBuild.ms, 2), + ivfQps: Math.round(qps(QUERY_COUNT, ivfMs)), + ivfMsPerQuery: round(ivfMs / QUERY_COUNT, 3), + ivfRecallAt10: round(recallAtK(exactResult.ids, ivfResult.ids, QUERY_COUNT, K), 3), + loadMs: round(load.ms, 2), + mmapLoadMs: round(mmapLoad.ms, 2), + mmapExactQps: Math.round(qps(QUERY_COUNT, mmapExactMs)), + filterBuildMs: round(filterBuild.ms, 2), + preparedFilterQps: Math.round(qps(QUERY_COUNT, preparedFilterMs)), + addLoggedMs: round(addLogged.ms, 2), + removeLoggedMs: round(removeLogged.ms, 2), + deltaKb: round(kb(fileSize(delta)), 1) + } + } finally { + if (filter !== null) await filter.dispose() + if (loaded !== null) await loaded.dispose() + if (mmap !== null) await mmap.dispose() + if (idx !== null) await idx.dispose() + cleanupFile(snapshot) + cleanupFile(delta) + } +} + +function buildNotes(results, bruteForceQps) { + const notes = [] + const f32 = results.find((item) => item.storage === 'f32') + const fastestExact = results.reduce((best, item) => { + return best === null || item.exactQps > best.exactQps ? item : best + }, null) + + if (fastestExact !== null) { + notes.push(`${fastestExact.storage} had the fastest exact search in this run at ${fastestExact.exactQps} q/s.`) + } + if (fastestExact !== null && f32 && fastestExact.storage !== 'f32') { + notes.push(`${fastestExact.storage} exact search was ${round(fastestExact.exactQps / f32.exactQps, 2)}x faster than current f32.`) + } + if (fastestExact !== null && bruteForceQps > 0) { + notes.push(`${fastestExact.storage} exact search was ${round(fastestExact.exactQps / bruteForceQps, 2)}x faster than JS brute-force search.`) + } + for (const item of results) { + if (f32 && item.storage !== 'f32') { + notes.push(`${item.storage} snapshot size is ${round(item.fileMb / f32.fileMb, 2)}x of f32 for this dataset.`) + } + notes.push(`${item.storage} IVF search is ${round(item.exactMsPerQuery / item.ivfMsPerQuery, 2)}x faster than exact search at recall ${item.ivfRecallAt10}.`) + } + notes.push('Lower bit widths reduce persisted size; recall and latency depend on vector distribution and IVF settings.') + notes.push('removeLogged is measured as 128 individual durable remove appends, not a bulk delete API.') + notes.push('This benchmark uses normalized synthetic vectors, so it measures index mechanics rather than model embedding quality.') + return notes +} + +function toMarkdown(report) { + const lines = [] + lines.push('# IdMapIndex TurboVec CPU Benchmark') + lines.push('') + lines.push(`- Generated: ${report.generatedAt}`) + lines.push('- Command: `bare benchmarks/performance/id-map-index-turbovec-cpu.js`') + lines.push(`- Runtime: ${report.runtime}`) + lines.push(`- Platform: ${report.platform}`) + lines.push(`- Dataset: ${report.vectorCount} vectors x ${report.dim} dimensions`) + lines.push(`- Queries: ${report.queryCount}, top-k: ${report.k}`) + lines.push(`- IVF: ${report.ivfLists} lists, nProbe ${report.ivfNprobe}`) + lines.push(`- Search timings: median of ${report.searchRuns} measured runs after one warmup`) + lines.push('') + lines.push('## Comparison To `main`') + lines.push('') + lines.push( + `Built and checked \`main\` at \`${report.mainBaselineCommit}\` ` + + `(${report.mainBuildStatus}): ` + + '`packages/embed-llamacpp` does not contain the `IdMapIndex` JS API, ' + + 'the vector-index native binding, or matching tests. There is no same-API ' + + 'CPU vector-index baseline to run on `main`.' + ) + lines.push('') + lines.push( + 'No local GGUF model was present in either worktree, so embedding-throughput ' + + 'comparison was not run. Because `main` only exposes embeddings, the closest ' + + 'same-data retrieval baseline is JS brute-force dot-product search over the ' + + 'embedding arrays.' + ) + lines.push('') + lines.push(`JS brute-force exact search baseline: ${report.bruteForceQps} q/s (${report.bruteForceMsPerQuery} ms/query).`) + lines.push('') + lines.push('## Main CPU Results') + lines.push('') + lines.push('| Storage | .tvim MB | Ingest vectors/s | Exact q/s | Exact ms/query | IVF build ms | IVF q/s | IVF ms/query | IVF recall@10 |') + lines.push('|---|---:|---:|---:|---:|---:|---:|---:|---:|') + for (const item of report.results) { + lines.push( + `| ${item.storage} | ${item.fileMb} | ${item.ingestVectorsPerSec}` + + ` | ${item.exactQps} | ${item.exactMsPerQuery}` + + ` | ${item.ivfBuildMs} | ${item.ivfQps} | ${item.ivfMsPerQuery}` + + ` | ${item.ivfRecallAt10} |` + ) + } + lines.push('') + lines.push('## Persistence, Filter, Mmap, Delta') + lines.push('') + lines.push('| Storage | Load ms | Mmap load ms | Mmap exact q/s | Prepared filter build ms | Prepared filter q/s | addLogged 128 ms | removeLogged 128 total ms | Delta KB |') + lines.push('|---|---:|---:|---:|---:|---:|---:|---:|---:|') + for (const item of report.results) { + lines.push( + `| ${item.storage} | ${item.loadMs} | ${item.mmapLoadMs}` + + ` | ${item.mmapExactQps} | ${item.filterBuildMs}` + + ` | ${item.preparedFilterQps} | ${item.addLoggedMs}` + + ` | ${item.removeLoggedMs} | ${item.deltaKb} |` + ) + } + lines.push('') + lines.push('## Notes') + lines.push('') + for (const note of report.notes) { + lines.push(`- ${note}`) + } + lines.push('') + return `${lines.join('\n')}\n` +} + +async function main() { + ensureTmpDir() + console.log('Generating synthetic vectors...') + const vectors = createNormalizedVectors(VECTOR_COUNT, DIM, 0x5eed1234) + const ids = createIds(VECTOR_COUNT, 1000000) + const queries = createQueries(vectors, VECTOR_COUNT, DIM, QUERY_COUNT) + const filterIds = createFilterIds(ids, FILTER_COUNT) + const deltaVectors = createNormalizedVectors(DELTA_COUNT, DIM, 0x9e3779b9) + + console.log('Benchmarking JS brute-force baseline...') + bruteForceSearch(vectors, ids, queries.subarray(0, DIM * 8), VECTOR_COUNT, DIM, 8, K) + const bruteForce = measureSync(() => { + bruteForceSearch(vectors, ids, queries, VECTOR_COUNT, DIM, QUERY_COUNT, K) + }) + + const results = [] + for (const bitWidth of BIT_WIDTHS) { + console.log(`Benchmarking bitWidth=${bitWidth}...`) + results.push(await runCase(bitWidth, vectors, ids, queries, filterIds, deltaVectors)) + } + + const bruteForceQps = qps(QUERY_COUNT, bruteForce.ms) + const report = { + generatedAt: new Date().toISOString(), + runtime: process.version || 'unknown', + platform: `${process.platform || 'unknown'} ${process.arch || 'unknown'}`, + mainBaselineCommit: MAIN_BASELINE_COMMIT, + mainBuildStatus: MAIN_BUILD_STATUS, + bruteForceQps: Math.round(bruteForceQps), + bruteForceMsPerQuery: round(bruteForce.ms / QUERY_COUNT, 3), + dim: DIM, + vectorCount: VECTOR_COUNT, + queryCount: QUERY_COUNT, + k: K, + ivfLists: IVF_LISTS, + ivfNprobe: IVF_NPROBE, + searchRuns: MEASURED_SEARCH_RUNS, + results, + notes: buildNotes(results, bruteForceQps) + } + + fs.writeFileSync(REPORT_PATH, toMarkdown(report)) + console.log(`Wrote ${REPORT_PATH}`) +} + +main().catch((err) => { + console.error(err && err.stack ? err.stack : err) + process.exit(1) +}) diff --git a/packages/embed-llamacpp/idMapIndex.d.ts b/packages/embed-llamacpp/idMapIndex.d.ts new file mode 100644 index 0000000000..5eca159bcd --- /dev/null +++ b/packages/embed-llamacpp/idMapIndex.d.ts @@ -0,0 +1,104 @@ +export interface IdMapIndexOptions { + /** Vector dimensionality (must be > 0). */ + dim: number + /** Storage precision: 4 = q4, 8 = q8, 32 = full f32 storage. Defaults to 8. */ + bitWidth?: 4 | 8 | 32 +} + +export interface IdMapIndexSearchResult { + /** Row-major dot-product scores: m * k. Higher = closer. */ + scores: Float32Array + /** Row-major external ids: m * k. `UINT64_MAX` padding when index is shorter than k. */ + ids: BigUint64Array + /** Number of query rows. */ + m: number + /** Effective k (mirrors the requested k). */ + k: number +} + +export class IdMapIndexFilter { + /** Search with the prepared allowlist. */ + search(queries: Float32Array, k: number): IdMapIndexSearchResult + + dispose(): Promise +} + +export default class IdMapIndex { + static Filter: typeof IdMapIndexFilter + static IdMapIndex: typeof IdMapIndex + static IdMapIndexFilter: typeof IdMapIndexFilter + + constructor(opts: IdMapIndexOptions) + + /** Open a persisted .tvim file written by `write()`. */ + static load(path: string): Promise + + /** Open a persisted .tvim file with mmap-backed vector storage. Mutations fail. */ + static loadMmap(path: string): Promise + + /** Open a persisted .tvim snapshot and replay an append-only .tvid delta log. */ + static loadWithDelta( + snapshotPath: string, + deltaPath: string + ): Promise + + /** + * Insert `n` vectors with stable external ids. Throws on duplicate id or + * dim mismatch; mutation is atomic per call. + */ + addWithIds(vectors: Float32Array, ids: BigUint64Array): void + + /** Insert vectors and append the mutation to an incremental .tvid delta log. */ + addLogged( + vectors: Float32Array, + ids: BigUint64Array, + deltaPath: string + ): void + + /** Top-k search across `queries.length / dim` rows. */ + search(queries: Float32Array, k: number): IdMapIndexSearchResult + + /** Top-k search restricted to the supplied allowed ids. */ + searchFiltered( + queries: Float32Array, + k: number, + allowedIds: BigUint64Array + ): IdMapIndexSearchResult + + /** Prepare an allowlist for repeated filtered searches. */ + prepareFilter(allowedIds: BigUint64Array): IdMapIndexFilter + + /** Build IVF-flat approximate search state. Mutations invalidate this state. */ + buildIvf(nLists: number, nIter?: number): void + + /** IVF-flat ANN top-k search. `buildIvf()` must have run after the latest mutation. */ + searchIvf(queries: Float32Array, k: number, nProbe: number): IdMapIndexSearchResult + + /** Returns true if removed, false if not present. */ + remove(id: bigint): boolean + + /** Remove an entry and append the mutation to an incremental .tvid delta log. */ + removeLogged(id: bigint, deltaPath: string): boolean + + /** Physically remove deleted slots from the in-memory index. */ + compact(): void + + contains(id: bigint): boolean + + /** Placeholder for cache warming / codebook resolution after bulk add. */ + prepare(): void + + /** Persist to disk (.tvim v2; legacy v1 files are still readable). */ + write(path: string): void + + /** Write a compacted full snapshot and reset the matching .tvid delta log. */ + compactDelta(snapshotPath: string, deltaPath: string): void + + readonly length: number + readonly dim: number + readonly bitWidth: 4 | 8 | 32 + + dispose(): Promise +} + +export { IdMapIndex } diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js index 361cb706fc..b819431fff 100644 --- a/packages/embed-llamacpp/idMapIndex.js +++ b/packages/embed-llamacpp/idMapIndex.js @@ -31,6 +31,12 @@ function ensureFilterHandle(self) { return self[FILTER_HANDLE] } +function syncPromise(fn) { + return new Promise((resolve) => { + resolve(fn()) + }) +} + class IdMapIndexFilter { search(queries, k) { if (!(queries instanceof Float32Array)) { @@ -49,12 +55,13 @@ class IdMapIndexFilter { } dispose() { - if (this[FILTER_HANDLE] !== null && this[FILTER_HANDLE] !== undefined) { - binding.idx_filter_dispose(this[FILTER_HANDLE]) - this[FILTER_HANDLE] = null - this[FILTER_OWNER] = null - } - return Promise.resolve() + return syncPromise(() => { + if (this[FILTER_HANDLE] !== null && this[FILTER_HANDLE] !== undefined) { + binding.idx_filter_dispose(this[FILTER_HANDLE]) + this[FILTER_HANDLE] = null + this[FILTER_OWNER] = null + } + }) } } @@ -86,12 +93,14 @@ class IdMapIndex { * @returns {Promise} */ static load(path) { - if (typeof path !== 'string' || path.length === 0) { - throw new TypeError('IdMapIndex.load: path must be a non-empty string') - } - const instance = Object.create(IdMapIndex.prototype) - instance[HANDLE] = binding.idx_load(path) - return Promise.resolve(instance) + return syncPromise(() => { + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('IdMapIndex.load: path must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load(path) + return instance + }) } /** @@ -101,12 +110,14 @@ class IdMapIndex { * @returns {Promise} */ static loadMmap(path) { - if (typeof path !== 'string' || path.length === 0) { - throw new TypeError('IdMapIndex.loadMmap: path must be a non-empty string') - } - const instance = Object.create(IdMapIndex.prototype) - instance[HANDLE] = binding.idx_load_mmap(path) - return Promise.resolve(instance) + return syncPromise(() => { + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('IdMapIndex.loadMmap: path must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load_mmap(path) + return instance + }) } /** @@ -116,15 +127,17 @@ class IdMapIndex { * @returns {Promise} */ static loadWithDelta(snapshotPath, deltaPath) { - if (typeof snapshotPath !== 'string' || snapshotPath.length === 0) { - throw new TypeError('IdMapIndex.loadWithDelta: snapshotPath must be a non-empty string') - } - if (typeof deltaPath !== 'string' || deltaPath.length === 0) { - throw new TypeError('IdMapIndex.loadWithDelta: deltaPath must be a non-empty string') - } - const instance = Object.create(IdMapIndex.prototype) - instance[HANDLE] = binding.idx_load_with_delta(snapshotPath, deltaPath) - return Promise.resolve(instance) + return syncPromise(() => { + if (typeof snapshotPath !== 'string' || snapshotPath.length === 0) { + throw new TypeError('IdMapIndex.loadWithDelta: snapshotPath must be a non-empty string') + } + if (typeof deltaPath !== 'string' || deltaPath.length === 0) { + throw new TypeError('IdMapIndex.loadWithDelta: deltaPath must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load_with_delta(snapshotPath, deltaPath) + return instance + }) } /** @@ -355,14 +368,16 @@ class IdMapIndex { * finalizer remains as a safety net if callers forget to dispose. */ dispose() { - if (this[HANDLE] !== null && this[HANDLE] !== undefined) { - binding.idx_dispose(this[HANDLE]) - this[HANDLE] = null - } - return Promise.resolve() + return syncPromise(() => { + if (this[HANDLE] !== null && this[HANDLE] !== undefined) { + binding.idx_dispose(this[HANDLE]) + this[HANDLE] = null + } + }) } } +IdMapIndex.IdMapIndex = IdMapIndex IdMapIndex.Filter = IdMapIndexFilter IdMapIndex.IdMapIndexFilter = IdMapIndexFilter diff --git a/packages/embed-llamacpp/index.d.ts b/packages/embed-llamacpp/index.d.ts index f95632fd83..e3f5a23c6e 100644 --- a/packages/embed-llamacpp/index.d.ts +++ b/packages/embed-llamacpp/index.d.ts @@ -88,105 +88,9 @@ export class BertInterface implements Addon { /** Returns the first shard (matching `-NNNNN-of-MMMMM.gguf`) or the sole entry for single-file models. */ export function pickPrimaryGgufPath(files: string[]): string -// --------------------------------------------------------------------------- -// IdMapIndex (turbovec) -// --------------------------------------------------------------------------- - -export interface IdMapIndexOptions { - /** Vector dimensionality (must be > 0). */ - dim: number - /** Storage precision: 4 = q4, 8 = q8, 32 = full f32 storage. Defaults to 8. */ - bitWidth?: 4 | 8 | 32 -} - -export interface IdMapIndexSearchResult { - /** Row-major dot-product scores: m * k. Higher = closer. */ - scores: Float32Array - /** Row-major external ids: m * k. `UINT64_MAX` padding when index is shorter than k. */ - ids: BigUint64Array - /** Number of query rows. */ - m: number - /** Effective k (mirrors the requested k). */ - k: number -} - -export class IdMapIndexFilter { - /** Search with the prepared allowlist. */ - search(queries: Float32Array, k: number): IdMapIndexSearchResult - - dispose(): Promise -} - -export class IdMapIndex { - constructor(opts: IdMapIndexOptions) - - /** Open a persisted .tvim file written by `write()`. */ - static load(path: string): Promise - - /** Open a persisted .tvim file with mmap-backed vector storage. Mutations fail. */ - static loadMmap(path: string): Promise - - /** Open a persisted .tvim snapshot and replay an append-only .tvid delta log. */ - static loadWithDelta( - snapshotPath: string, - deltaPath: string - ): Promise - - /** - * Insert `n` vectors with stable external ids. Throws on duplicate id or - * dim mismatch; mutation is atomic per call. - */ - addWithIds(vectors: Float32Array, ids: BigUint64Array): void - - /** Insert vectors and append the mutation to an incremental .tvid delta log. */ - addLogged( - vectors: Float32Array, - ids: BigUint64Array, - deltaPath: string - ): void - - /** Top-k search across `queries.length / dim` rows. */ - search(queries: Float32Array, k: number): IdMapIndexSearchResult - - /** Top-k search restricted to the supplied allowed ids. */ - searchFiltered( - queries: Float32Array, - k: number, - allowedIds: BigUint64Array - ): IdMapIndexSearchResult - - /** Prepare an allowlist for repeated filtered searches. */ - prepareFilter(allowedIds: BigUint64Array): IdMapIndexFilter - - /** Build IVF-flat approximate search state. Mutations invalidate this state. */ - buildIvf(nLists: number, nIter?: number): void - - /** IVF-flat ANN top-k search. `buildIvf()` must have run after the latest mutation. */ - searchIvf(queries: Float32Array, k: number, nProbe: number): IdMapIndexSearchResult - - /** Returns true if removed, false if not present. */ - remove(id: bigint): boolean - - /** Remove an entry and append the mutation to an incremental .tvid delta log. */ - removeLogged(id: bigint, deltaPath: string): boolean - - /** Physically remove deleted slots from the in-memory index. */ - compact(): void - - contains(id: bigint): boolean - - /** Placeholder for cache warming / codebook resolution after bulk add. */ - prepare(): void - - /** Persist to disk (.tvim v2; legacy v1 files are still readable). */ - write(path: string): void - - /** Write a compacted full snapshot and reset the matching .tvid delta log. */ - compactDelta(snapshotPath: string, deltaPath: string): void - - readonly length: number - readonly dim: number - readonly bitWidth: 4 | 8 | 32 - - dispose(): Promise -} +export { + default as IdMapIndex, + IdMapIndexFilter, + type IdMapIndexOptions, + type IdMapIndexSearchResult +} from './idMapIndex' diff --git a/packages/embed-llamacpp/index.js b/packages/embed-llamacpp/index.js index 5f71647a42..d18a643116 100644 --- a/packages/embed-llamacpp/index.js +++ b/packages/embed-llamacpp/index.js @@ -223,4 +223,6 @@ module.exports.pickPrimaryGgufPath = pickPrimaryGgufPath // addon require chain should import it directly via the package's // `./idMapIndex` sub-export — that path goes straight to `./binding` and // never touches `./addon`, satisfying the POC's lifecycle-isolation invariant. -module.exports.IdMapIndex = require('./idMapIndex') +const IdMapIndex = require('./idMapIndex') +module.exports.IdMapIndex = IdMapIndex +module.exports.IdMapIndexFilter = IdMapIndex.IdMapIndexFilter diff --git a/packages/embed-llamacpp/package.json b/packages/embed-llamacpp/package.json index 85fdd12e96..c30cd182dc 100644 --- a/packages/embed-llamacpp/package.json +++ b/packages/embed-llamacpp/package.json @@ -47,6 +47,7 @@ "addonLogging.d.ts", "prebuilds", "index.d.ts", + "idMapIndex.d.ts", "LICENSE", "NOTICE" ], @@ -82,7 +83,7 @@ "default": "./index.js" }, "./idMapIndex": { - "types": "./index.d.ts", + "types": "./idMapIndex.d.ts", "default": "./idMapIndex.js" }, "./addonLogging": { diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js index 637dc6bd0c..a6fce25ed5 100644 --- a/packages/embed-llamacpp/test/integration/id-map-index.test.js +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -215,6 +215,26 @@ test('IdMapIndex: validates production bit widths', (t) => { ) }) +test('IdMapIndex: promise APIs reject instead of throwing synchronously', async (t) => { + const load = IdMapIndex.load('') + t.ok(load && typeof load.catch === 'function', 'load returns a promise for invalid input') + await expectRejects(t, () => load, 'load invalid path rejects') + + const loadMmap = IdMapIndex.loadMmap('') + t.ok( + loadMmap && typeof loadMmap.catch === 'function', + 'loadMmap returns a promise for invalid input' + ) + await expectRejects(t, () => loadMmap, 'loadMmap invalid path rejects') + + const loadWithDelta = IdMapIndex.loadWithDelta('', '') + t.ok( + loadWithDelta && typeof loadWithDelta.catch === 'function', + 'loadWithDelta returns a promise for invalid input' + ) + await expectRejects(t, () => loadWithDelta, 'loadWithDelta invalid paths reject') +}) + test('IdMapIndex: rejects mismatched empty-id add', (t) => { const idx = new IdMapIndex({ dim: 2 }) expectThrows( diff --git a/packages/embed-llamacpp/tsconfig.dts.json b/packages/embed-llamacpp/tsconfig.dts.json index 3c6649b642..fca0c16cb7 100644 --- a/packages/embed-llamacpp/tsconfig.dts.json +++ b/packages/embed-llamacpp/tsconfig.dts.json @@ -13,6 +13,6 @@ "strict": true, "noEmit": true }, - "include": ["index.d.ts", "addonLogging.d.ts"] + "include": ["index.d.ts", "idMapIndex.d.ts", "addonLogging.d.ts"] } diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake index 485ea51bb9..d142f321fb 100644 --- a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake @@ -1,5 +1,9 @@ # Function to detect Vulkan version from NDK vulkan_core.h function(detect_ndk_vulkan_version) + if(NOT DEFINED ENV{ANDROID_NDK_HOME} OR "$ENV{ANDROID_NDK_HOME}" STREQUAL "") + message(FATAL_ERROR "ANDROID_NDK_HOME must be set for Android Vulkan header detection") + endif() + string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" host_system_name_lower) # CMAKE_HOST_SYSTEM_PROCESSOR is unavailable here. Use a glob pattern to complete the folder instead. @@ -9,11 +13,11 @@ function(detect_ndk_vulkan_version) get_filename_component(host_arch "${host_dir}" NAME) set(vulkan_core_h "$ENV{ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${host_arch}/sysroot/usr/include/vulkan/vulkan_core.h") else() - message(FATAL "Could not find NDK host directory for ${host_system_name_lower}") + message(FATAL_ERROR "Could not find NDK host directory for ${host_system_name_lower}") endif() - if(NOT vulkan_core_h) - message(FATAL "vulkan_core.h not found, using default version") + if(NOT EXISTS "${vulkan_core_h}") + message(FATAL_ERROR "vulkan_core.h not found at ${vulkan_core_h}") endif() file(READ "${vulkan_core_h}" header_content) @@ -21,7 +25,7 @@ function(detect_ndk_vulkan_version) if(version_match) set(header_version_3 "${CMAKE_MATCH_1}") else() - message(FATAL "Could not extract VK_HEADER_VERSION from vulkan_core.h, using default: ${vulkan_version}") + message(FATAL_ERROR "Could not extract VK_HEADER_VERSION from ${vulkan_core_h}") endif() # Extract major.minor version from VK_HEADER_VERSION_COMPLETE for download URL @@ -31,6 +35,18 @@ function(detect_ndk_vulkan_version) set(minor "${CMAKE_MATCH_3}") set(vulkan_version "${major}.${minor}.${header_version_3}" PARENT_SCOPE) else() - message(FATAL "Could not extract major.minor version from vulkan_core.h, using default: ${vulkan_version}") + message(FATAL_ERROR "Could not extract Vulkan major.minor version from ${vulkan_core_h}") + endif() +endfunction() + +function(resolve_vulkan_headers_sha512 version out_var) + if(version STREQUAL "1.3.275") + set(${out_var} + "adebfc61501e67367d366a8b17833d064f925ada6480641ef3c128bbda3852087e02d67a09e90b2c188a47494b7e47a87db0d039465858e765e89dc6c2b370d7" + PARENT_SCOPE) + else() + message(FATAL_ERROR + "Unsupported Android NDK Vulkan header version '${version}'. " + "Add the matching KhronosGroup/Vulkan-Headers archive SHA512 before building.") endif() endfunction() diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake index 2c0500809f..353d9d09ce 100644 --- a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake @@ -68,14 +68,15 @@ vcpkg_check_features( if (VCPKG_TARGET_IS_ANDROID) include(${CMAKE_CURRENT_LIST_DIR}/android-vulkan-version.cmake) detect_ndk_vulkan_version() + resolve_vulkan_headers_sha512("${vulkan_version}" vulkan_headers_sha512) message(STATUS "Using Vulkan C++ wrappers from version: ${vulkan_version}") - file(DOWNLOAD - "https://github.com/KhronosGroup/Vulkan-Headers/archive/refs/tags/v${vulkan_version}.tar.gz" - "${SOURCE_PATH}/vulkan-sdk-${vulkan_version}.tar.gz" - TLS_VERIFY ON + vcpkg_download_distfile(VULKAN_HEADERS_ARCHIVE + URLS "https://github.com/KhronosGroup/Vulkan-Headers/archive/refs/tags/v${vulkan_version}.tar.gz" + FILENAME "KhronosGroup-Vulkan-Headers-v${vulkan_version}.tar.gz" + SHA512 "${vulkan_headers_sha512}" ) file(ARCHIVE_EXTRACT - INPUT "${SOURCE_PATH}/vulkan-sdk-${vulkan_version}.tar.gz" + INPUT "${VULKAN_HEADERS_ARCHIVE}" DESTINATION "${SOURCE_PATH}" PATTERNS "*.hpp" ) From 781e64110ada12b35fc330d754a7bd1527d886cb Mon Sep 17 00:00:00 2001 From: Nidhin Date: Thu, 16 Jul 2026 18:26:37 +0530 Subject: [PATCH 06/11] fix: preserve lazy addon loading for IdMapIndex export --- packages/embed-llamacpp/index.js | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/embed-llamacpp/index.js b/packages/embed-llamacpp/index.js index d18a643116..4ead989eef 100644 --- a/packages/embed-llamacpp/index.js +++ b/packages/embed-llamacpp/index.js @@ -217,12 +217,20 @@ class GGMLBert { module.exports = GGMLBert module.exports.pickPrimaryGgufPath = pickPrimaryGgufPath -// IdMapIndex (turbovec POC) is also re-exported here as a named property -// for discoverability when consumers `require('@qvac/embed-llamacpp')`. -// Consumers that want the ANN index WITHOUT loading this file's GGMLBert / -// addon require chain should import it directly via the package's -// `./idMapIndex` sub-export — that path goes straight to `./binding` and -// never touches `./addon`, satisfying the POC's lifecycle-isolation invariant. -const IdMapIndex = require('./idMapIndex') -module.exports.IdMapIndex = IdMapIndex -module.exports.IdMapIndexFilter = IdMapIndex.IdMapIndexFilter +// IdMapIndex is exposed lazily so the main package entry keeps its historical +// behavior: importing GGMLBert does not load the native addon until needed. +function loadIdMapIndex() { + return require('./idMapIndex') +} + +Object.defineProperty(module.exports, 'IdMapIndex', { + enumerable: true, + get: loadIdMapIndex +}) + +Object.defineProperty(module.exports, 'IdMapIndexFilter', { + enumerable: true, + get() { + return loadIdMapIndex().IdMapIndexFilter + } +}) From f73fcfd701d4e94caf7dfb1a01f4ca0676e434e3 Mon Sep 17 00:00:00 2001 From: Nidhin Date: Thu, 16 Jul 2026 18:49:01 +0530 Subject: [PATCH 07/11] fix[api]: reserve IdMapIndex padding sentinel --- .../addon/src/js-interface/vector-index-binding.cpp | 10 ++++++++++ packages/embed-llamacpp/idMapIndex.d.ts | 8 ++++++-- .../test/integration/id-map-index.test.js | 5 +++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp index ca3090feff..89e7c143e8 100644 --- a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -313,6 +313,16 @@ bool read_vector_batch( js_throw_range_error(env, "InvalidArgument", "too many vectors in batch"); return false; } + const uint64_t padding_id = std::numeric_limits::max(); + for (size_t i = 0; i < ilen; i++) { + if (ids[i] == padding_id) { + js_throw_range_error( + env, + "InvalidArgument", + "UINT64_MAX is reserved for search result padding"); + return false; + } + } out->vectors = vectors; out->ids = ids; diff --git a/packages/embed-llamacpp/idMapIndex.d.ts b/packages/embed-llamacpp/idMapIndex.d.ts index 5eca159bcd..38a132c1d9 100644 --- a/packages/embed-llamacpp/idMapIndex.d.ts +++ b/packages/embed-llamacpp/idMapIndex.d.ts @@ -44,11 +44,15 @@ export default class IdMapIndex { /** * Insert `n` vectors with stable external ids. Throws on duplicate id or - * dim mismatch; mutation is atomic per call. + * dim mismatch; mutation is atomic per call. `UINT64_MAX` is reserved for + * search result padding and cannot be inserted. */ addWithIds(vectors: Float32Array, ids: BigUint64Array): void - /** Insert vectors and append the mutation to an incremental .tvid delta log. */ + /** + * Insert vectors and append the mutation to an incremental .tvid delta log. + * `UINT64_MAX` is reserved for search result padding and cannot be inserted. + */ addLogged( vectors: Float32Array, ids: BigUint64Array, diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js index a6fce25ed5..7158dffd7c 100644 --- a/packages/embed-llamacpp/test/integration/id-map-index.test.js +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -258,6 +258,11 @@ test('IdMapIndex: BigInt id range edge cases', (t) => { t.ok(idx.contains(edge), 'high-bit id round-trips') const out = idx.search(new Float32Array([0.5, 0.5]), 1) t.is(out.ids[0], edge, 'high-bit id surfaces from search') + expectThrows( + t, + () => idx.addWithIds(new Float32Array([1, 0]), new BigUint64Array([UINT64_MAX])), + 'UINT64_MAX id should be rejected because it is reserved for padding' + ) idx.dispose() }) From 858e1e6701bea8c8bd6b7de8a4ec4674cfe19976 Mon Sep 17 00:00:00 2001 From: Nidhin Date: Thu, 16 Jul 2026 21:15:22 +0530 Subject: [PATCH 08/11] fix[api]: make IdMapIndex load APIs synchronous --- .../performance/id-map-index-turbovec-cpu.js | 30 ++--- packages/embed-llamacpp/idMapIndex.d.ts | 16 +-- packages/embed-llamacpp/idMapIndex.js | 85 ++++++------- .../test/integration/id-map-index.test.js | 117 +++++++----------- 4 files changed, 104 insertions(+), 144 deletions(-) diff --git a/packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js b/packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js index b970972b69..b433ccd2f0 100644 --- a/packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js +++ b/packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js @@ -84,12 +84,6 @@ function measureSync(fn) { return { ms: nowMs() - start, value } } -async function measureAsync(fn) { - const start = nowMs() - const value = await fn() - return { ms: nowMs() - start, value } -} - function median(values) { const sorted = values.slice().sort((a, b) => a - b) return sorted[Math.floor(sorted.length / 2)] @@ -182,7 +176,7 @@ function bruteForceSearch(vectors, ids, queries, nVectors, dim, nQueries, k) { return { ids: outIds, scores: outScores } } -async function runCase(bitWidth, vectors, ids, queries, filterIds, deltaVectors) { +function runCase(bitWidth, vectors, ids, queries, filterIds, deltaVectors) { const storage = bitWidth === 32 ? 'f32' : `q${bitWidth}` const snapshot = path.join(TMP_DIR, `id-map-index-${storage}.tvim`) const delta = path.join(TMP_DIR, `id-map-index-${storage}.tvid`) @@ -228,9 +222,9 @@ async function runCase(bitWidth, vectors, ids, queries, filterIds, deltaVectors) }) const sizeBytes = fileSize(snapshot) - const load = await measureAsync(() => IdMapIndex.load(snapshot)) + const load = measureSync(() => IdMapIndex.load(snapshot)) loaded = load.value - const mmapLoad = await measureAsync(() => IdMapIndex.loadMmap(snapshot)) + const mmapLoad = measureSync(() => IdMapIndex.loadMmap(snapshot)) mmap = mmapLoad.value mmap.search(queries.subarray(0, DIM * 8), K) const mmapExactMs = measureMedianMs(() => { @@ -267,10 +261,10 @@ async function runCase(bitWidth, vectors, ids, queries, filterIds, deltaVectors) deltaKb: round(kb(fileSize(delta)), 1) } } finally { - if (filter !== null) await filter.dispose() - if (loaded !== null) await loaded.dispose() - if (mmap !== null) await mmap.dispose() - if (idx !== null) await idx.dispose() + if (filter !== null) filter.dispose() + if (loaded !== null) loaded.dispose() + if (mmap !== null) mmap.dispose() + if (idx !== null) idx.dispose() cleanupFile(snapshot) cleanupFile(delta) } @@ -371,7 +365,7 @@ function toMarkdown(report) { return `${lines.join('\n')}\n` } -async function main() { +function main() { ensureTmpDir() console.log('Generating synthetic vectors...') const vectors = createNormalizedVectors(VECTOR_COUNT, DIM, 0x5eed1234) @@ -389,7 +383,7 @@ async function main() { const results = [] for (const bitWidth of BIT_WIDTHS) { console.log(`Benchmarking bitWidth=${bitWidth}...`) - results.push(await runCase(bitWidth, vectors, ids, queries, filterIds, deltaVectors)) + results.push(runCase(bitWidth, vectors, ids, queries, filterIds, deltaVectors)) } const bruteForceQps = qps(QUERY_COUNT, bruteForce.ms) @@ -416,7 +410,9 @@ async function main() { console.log(`Wrote ${REPORT_PATH}`) } -main().catch((err) => { +try { + main() +} catch (err) { console.error(err && err.stack ? err.stack : err) process.exit(1) -}) +} diff --git a/packages/embed-llamacpp/idMapIndex.d.ts b/packages/embed-llamacpp/idMapIndex.d.ts index 38a132c1d9..c449b57547 100644 --- a/packages/embed-llamacpp/idMapIndex.d.ts +++ b/packages/embed-llamacpp/idMapIndex.d.ts @@ -20,7 +20,7 @@ export class IdMapIndexFilter { /** Search with the prepared allowlist. */ search(queries: Float32Array, k: number): IdMapIndexSearchResult - dispose(): Promise + dispose(): void } export default class IdMapIndex { @@ -30,17 +30,17 @@ export default class IdMapIndex { constructor(opts: IdMapIndexOptions) - /** Open a persisted .tvim file written by `write()`. */ - static load(path: string): Promise + /** Open a persisted .tvim file written by `write()`. Synchronous; may block for large indexes. */ + static load(path: string): IdMapIndex - /** Open a persisted .tvim file with mmap-backed vector storage. Mutations fail. */ - static loadMmap(path: string): Promise + /** Open a persisted .tvim file with mmap-backed vector storage. Synchronous; mutations fail. */ + static loadMmap(path: string): IdMapIndex - /** Open a persisted .tvim snapshot and replay an append-only .tvid delta log. */ + /** Open a persisted .tvim snapshot and replay an append-only .tvid delta log. Synchronous; may block for large indexes. */ static loadWithDelta( snapshotPath: string, deltaPath: string - ): Promise + ): IdMapIndex /** * Insert `n` vectors with stable external ids. Throws on duplicate id or @@ -102,7 +102,7 @@ export default class IdMapIndex { readonly dim: number readonly bitWidth: 4 | 8 | 32 - dispose(): Promise + dispose(): void } export { IdMapIndex } diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js index b819431fff..703550122f 100644 --- a/packages/embed-llamacpp/idMapIndex.js +++ b/packages/embed-llamacpp/idMapIndex.js @@ -31,12 +31,6 @@ function ensureFilterHandle(self) { return self[FILTER_HANDLE] } -function syncPromise(fn) { - return new Promise((resolve) => { - resolve(fn()) - }) -} - class IdMapIndexFilter { search(queries, k) { if (!(queries instanceof Float32Array)) { @@ -55,13 +49,11 @@ class IdMapIndexFilter { } dispose() { - return syncPromise(() => { - if (this[FILTER_HANDLE] !== null && this[FILTER_HANDLE] !== undefined) { - binding.idx_filter_dispose(this[FILTER_HANDLE]) - this[FILTER_HANDLE] = null - this[FILTER_OWNER] = null - } - }) + if (this[FILTER_HANDLE] !== null && this[FILTER_HANDLE] !== undefined) { + binding.idx_filter_dispose(this[FILTER_HANDLE]) + this[FILTER_HANDLE] = null + this[FILTER_OWNER] = null + } } } @@ -89,55 +81,52 @@ class IdMapIndex { /** * Load a persisted index from a .tvim file. Returns a fresh instance. + * This is synchronous and may block for large persisted indexes. * @param {string} path - * @returns {Promise} + * @returns {IdMapIndex} */ static load(path) { - return syncPromise(() => { - if (typeof path !== 'string' || path.length === 0) { - throw new TypeError('IdMapIndex.load: path must be a non-empty string') - } - const instance = Object.create(IdMapIndex.prototype) - instance[HANDLE] = binding.idx_load(path) - return instance - }) + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('IdMapIndex.load: path must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load(path) + return instance } /** * Load a persisted .tvim file with mmap-backed vector storage. The returned * index is read-only for mutating operations. + * This is synchronous and may block for large persisted indexes. * @param {string} path - * @returns {Promise} + * @returns {IdMapIndex} */ static loadMmap(path) { - return syncPromise(() => { - if (typeof path !== 'string' || path.length === 0) { - throw new TypeError('IdMapIndex.loadMmap: path must be a non-empty string') - } - const instance = Object.create(IdMapIndex.prototype) - instance[HANDLE] = binding.idx_load_mmap(path) - return instance - }) + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('IdMapIndex.loadMmap: path must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load_mmap(path) + return instance } /** * Load a full snapshot and replay an append-only delta log. + * This is synchronous and may block for large persisted indexes. * @param {string} snapshotPath * @param {string} deltaPath - * @returns {Promise} + * @returns {IdMapIndex} */ static loadWithDelta(snapshotPath, deltaPath) { - return syncPromise(() => { - if (typeof snapshotPath !== 'string' || snapshotPath.length === 0) { - throw new TypeError('IdMapIndex.loadWithDelta: snapshotPath must be a non-empty string') - } - if (typeof deltaPath !== 'string' || deltaPath.length === 0) { - throw new TypeError('IdMapIndex.loadWithDelta: deltaPath must be a non-empty string') - } - const instance = Object.create(IdMapIndex.prototype) - instance[HANDLE] = binding.idx_load_with_delta(snapshotPath, deltaPath) - return instance - }) + if (typeof snapshotPath !== 'string' || snapshotPath.length === 0) { + throw new TypeError('IdMapIndex.loadWithDelta: snapshotPath must be a non-empty string') + } + if (typeof deltaPath !== 'string' || deltaPath.length === 0) { + throw new TypeError('IdMapIndex.loadWithDelta: deltaPath must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load_with_delta(snapshotPath, deltaPath) + return instance } /** @@ -368,12 +357,10 @@ class IdMapIndex { * finalizer remains as a safety net if callers forget to dispose. */ dispose() { - return syncPromise(() => { - if (this[HANDLE] !== null && this[HANDLE] !== undefined) { - binding.idx_dispose(this[HANDLE]) - this[HANDLE] = null - } - }) + if (this[HANDLE] !== null && this[HANDLE] !== undefined) { + binding.idx_dispose(this[HANDLE]) + this[HANDLE] = null + } } } diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js index 7158dffd7c..31b180d284 100644 --- a/packages/embed-llamacpp/test/integration/id-map-index.test.js +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -60,15 +60,6 @@ function expectThrows(t, fn, message) { } } -async function expectRejects(t, fn, message) { - try { - await fn() - t.fail(message) - } catch (e) { - t.pass(`${message}: ${e.message || e.code}`) - } -} - function assertTvimV2Header(t, file, bitWidth) { const bytes = fs.readFileSync(file) t.is(bytes[0], 0x54, 'tvim magic T') @@ -88,7 +79,7 @@ function assertTvidHeader(t, file) { t.is(bytes[4], 1, 'tvid version 1') } -async function runRoundTrip(t, bitWidth) { +function runRoundTrip(t, bitWidth) { const idx = new IdMapIndex({ dim: DIM, bitWidth }) const vectors = new Float32Array(N * DIM) @@ -156,9 +147,9 @@ async function runRoundTrip(t, bitWidth) { try { idx.write(file) assertTvimV2Header(t, file, bitWidth) - await idx.dispose() + idx.dispose() - const loaded = await IdMapIndex.load(file) + const loaded = IdMapIndex.load(file) t.is(loaded.dim, DIM, `dim restored (${bitWidth})`) t.is(loaded.bitWidth, bitWidth, `bitWidth restored (${bitWidth})`) t.is(loaded.length, N - 1, `length restored (${bitWidth})`) @@ -169,7 +160,7 @@ async function runRoundTrip(t, bitWidth) { t.is(out.ids[0], ids[0], `self-match after reload (${bitWidth})`) t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5, `score≈1.0 after reload (${bitWidth})`) - await loaded.dispose() + loaded.dispose() } finally { if (fs.existsSync(file)) fs.unlinkSync(file) } @@ -195,16 +186,16 @@ test('IdMapIndex sub-export does not boot the BERT runtime', (t) => { t.absent(require.cache[KEY_ADDON], 'BertInterface plumbing (addon.js) NOT loaded by ./idMapIndex') }) -test('IdMapIndex: q4 add + search + remove + persistence round-trip', async (t) => { - await runRoundTrip(t, 4) +test('IdMapIndex: q4 add + search + remove + persistence round-trip', (t) => { + runRoundTrip(t, 4) }) -test('IdMapIndex: q8 add + search + remove + persistence round-trip', async (t) => { - await runRoundTrip(t, 8) +test('IdMapIndex: q8 add + search + remove + persistence round-trip', (t) => { + runRoundTrip(t, 8) }) -test('IdMapIndex: f32 add + search + remove + persistence round-trip', async (t) => { - await runRoundTrip(t, 32) +test('IdMapIndex: f32 add + search + remove + persistence round-trip', (t) => { + runRoundTrip(t, 32) }) test('IdMapIndex: validates production bit widths', (t) => { @@ -215,24 +206,10 @@ test('IdMapIndex: validates production bit widths', (t) => { ) }) -test('IdMapIndex: promise APIs reject instead of throwing synchronously', async (t) => { - const load = IdMapIndex.load('') - t.ok(load && typeof load.catch === 'function', 'load returns a promise for invalid input') - await expectRejects(t, () => load, 'load invalid path rejects') - - const loadMmap = IdMapIndex.loadMmap('') - t.ok( - loadMmap && typeof loadMmap.catch === 'function', - 'loadMmap returns a promise for invalid input' - ) - await expectRejects(t, () => loadMmap, 'loadMmap invalid path rejects') - - const loadWithDelta = IdMapIndex.loadWithDelta('', '') - t.ok( - loadWithDelta && typeof loadWithDelta.catch === 'function', - 'loadWithDelta returns a promise for invalid input' - ) - await expectRejects(t, () => loadWithDelta, 'loadWithDelta invalid paths reject') +test('IdMapIndex: load APIs throw synchronously for invalid input', (t) => { + expectThrows(t, () => IdMapIndex.load(''), 'load invalid path throws') + expectThrows(t, () => IdMapIndex.loadMmap(''), 'loadMmap invalid path throws') + expectThrows(t, () => IdMapIndex.loadWithDelta('', ''), 'loadWithDelta invalid paths throw') }) test('IdMapIndex: rejects mismatched empty-id add', (t) => { @@ -289,7 +266,7 @@ test('IdMapIndex: filtered search restricts allowed ids', (t) => { idx.dispose() }) -test('IdMapIndex: prepared filters are reusable and invalidated by mutation', async (t) => { +test('IdMapIndex: prepared filters are reusable and invalidated by mutation', (t) => { const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) let filter = null try { @@ -310,8 +287,8 @@ test('IdMapIndex: prepared filters are reusable and invalidated by mutation', as 'stale prepared filter should throw' ) - await filter.dispose() - await filter.dispose() + filter.dispose() + filter.dispose() expectThrows( t, () => filter.search(new Float32Array([1, 0]), 1), @@ -319,8 +296,8 @@ test('IdMapIndex: prepared filters are reusable and invalidated by mutation', as ) filter = null } finally { - if (filter !== null) await filter.dispose() - await idx.dispose() + if (filter !== null) filter.dispose() + idx.dispose() } }) @@ -355,7 +332,7 @@ test('IdMapIndex: IVF build and search lifecycle', (t) => { idx.dispose() }) -test('IdMapIndex: delta log replay and compaction', async (t) => { +test('IdMapIndex: delta log replay and compaction', (t) => { const snapshot = tmpPath('id-map-index-delta-snapshot') const delta = tmpDeltaPath('id-map-index-delta-log') let idx = null @@ -371,7 +348,7 @@ test('IdMapIndex: delta log replay and compaction', async (t) => { t.absent(idx.removeLogged(44n, delta), 'logged remove returns false for absent id') t.is(idx.length, 2, 'logged mutations update live index') - replayed = await IdMapIndex.loadWithDelta(snapshot, delta) + replayed = IdMapIndex.loadWithDelta(snapshot, delta) t.is(replayed.dim, 2, 'delta replay dim restored') t.is(replayed.bitWidth, 4, 'delta replay bitWidth restored') t.absent(replayed.contains(11n), 'delta replay applies remove') @@ -382,29 +359,29 @@ test('IdMapIndex: delta log replay and compaction', async (t) => { 33n, 'delta replay search sees added id' ) - await replayed.dispose() + replayed.dispose() replayed = null idx.compactDelta(snapshot, delta) assertTvimV2Header(t, snapshot, 4) assertTvidHeader(t, delta) - await idx.dispose() + idx.dispose() idx = null - compacted = await IdMapIndex.loadWithDelta(snapshot, delta) + compacted = IdMapIndex.loadWithDelta(snapshot, delta) t.absent(compacted.contains(11n), 'compacted snapshot excludes removed id') t.ok(compacted.contains(22n), 'compacted snapshot keeps existing id') t.ok(compacted.contains(33n), 'compacted snapshot includes logged add') } finally { - if (idx !== null) await idx.dispose() - if (replayed !== null) await replayed.dispose() - if (compacted !== null) await compacted.dispose() + if (idx !== null) idx.dispose() + if (replayed !== null) replayed.dispose() + if (compacted !== null) compacted.dispose() if (fs.existsSync(snapshot)) fs.unlinkSync(snapshot) if (fs.existsSync(delta)) fs.unlinkSync(delta) } }) -test('IdMapIndex: missing delta log replays as empty', async (t) => { +test('IdMapIndex: missing delta log replays as empty', (t) => { const snapshot = tmpPath('id-map-index-missing-delta-snapshot') const delta = tmpDeltaPath('id-map-index-missing-delta-log') let idx = null @@ -413,25 +390,25 @@ test('IdMapIndex: missing delta log replays as empty', async (t) => { idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) idx.addWithIds(new Float32Array([1, 0, 0, 1]), new BigUint64Array([11n, 22n])) idx.write(snapshot) - await idx.dispose() + idx.dispose() idx = null t.absent(fs.existsSync(delta), 'delta log does not exist before load') - loaded = await IdMapIndex.loadWithDelta(snapshot, delta) + loaded = IdMapIndex.loadWithDelta(snapshot, delta) t.is(loaded.dim, 2, 'snapshot dim restored without delta') t.is(loaded.bitWidth, 4, 'snapshot bitWidth restored without delta') t.is(loaded.length, 2, 'snapshot length restored without delta') t.ok(loaded.contains(11n), 'snapshot id 11 restored without delta') t.ok(loaded.contains(22n), 'snapshot id 22 restored without delta') } finally { - if (idx !== null) await idx.dispose() - if (loaded !== null) await loaded.dispose() + if (idx !== null) idx.dispose() + if (loaded !== null) loaded.dispose() if (fs.existsSync(snapshot)) fs.unlinkSync(snapshot) if (fs.existsSync(delta)) fs.unlinkSync(delta) } }) -test('IdMapIndex: corrupt delta log replay fails', async (t) => { +test('IdMapIndex: corrupt delta log replay fails', (t) => { const snapshot = tmpPath('id-map-index-corrupt-delta-snapshot') const delta = tmpDeltaPath('id-map-index-corrupt-delta-log') let idx = null @@ -439,17 +416,17 @@ test('IdMapIndex: corrupt delta log replay fails', async (t) => { idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) idx.addWithIds(new Float32Array([1, 0]), new BigUint64Array([11n])) idx.write(snapshot) - await idx.dispose() + idx.dispose() idx = null fs.writeFileSync(delta, new Uint8Array([0, 1, 2, 3, 4, 5])) - await expectRejects( + expectThrows( t, () => IdMapIndex.loadWithDelta(snapshot, delta), - 'corrupt delta log should be rejected' + 'corrupt delta log should throw' ) } finally { - if (idx !== null) await idx.dispose() + if (idx !== null) idx.dispose() if (fs.existsSync(snapshot)) fs.unlinkSync(snapshot) if (fs.existsSync(delta)) fs.unlinkSync(delta) } @@ -475,7 +452,7 @@ test('IdMapIndex: rejects non-finite vectors and queries', (t) => { idx.dispose() }) -test('IdMapIndex: mmap load is searchable and read-only', async (t) => { +test('IdMapIndex: mmap load is searchable and read-only', (t) => { const file = tmpPath('id-map-index-mmap') let mmap = null let filter = null @@ -483,9 +460,9 @@ test('IdMapIndex: mmap load is searchable and read-only', async (t) => { const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) idx.addWithIds(new Float32Array([1, 0, 0, 1]), new BigUint64Array([11n, 22n])) idx.write(file) - await idx.dispose() + idx.dispose() - mmap = await IdMapIndex.loadMmap(file) + mmap = IdMapIndex.loadMmap(file) t.is(mmap.dim, 2, 'mmap dim restored') t.is(mmap.bitWidth, 4, 'mmap bitWidth restored') t.is(mmap.length, 2, 'mmap length restored') @@ -511,28 +488,28 @@ test('IdMapIndex: mmap load is searchable and read-only', async (t) => { expectThrows(t, () => mmap.remove(11n), 'mmap remove should be rejected') expectThrows(t, () => mmap.compact(), 'mmap compact should be rejected') } finally { - if (filter !== null) await filter.dispose() - if (mmap !== null) await mmap.dispose() + if (filter !== null) filter.dispose() + if (mmap !== null) mmap.dispose() if (fs.existsSync(file)) fs.unlinkSync(file) } }) -test('IdMapIndex: dispose is deterministic and idempotent', async (t) => { +test('IdMapIndex: dispose is deterministic and idempotent', (t) => { const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) idx.addWithIds(new Float32Array([1, 0]), new BigUint64Array([1n])) - await idx.dispose() - await idx.dispose() + idx.dispose() + idx.dispose() expectThrows(t, () => idx.contains(1n), 'disposed contains should throw') expectThrows(t, () => idx.search(new Float32Array([1, 0]), 1), 'disposed search should throw') expectThrows(t, () => idx.compact(), 'disposed compact should throw') }) -test('IdMapIndex: corrupt persistence file load fails', async (t) => { +test('IdMapIndex: corrupt persistence file load fails', (t) => { const file = tmpPath('id-map-index-corrupt') try { fs.writeFileSync(file, new Uint8Array([0, 1, 2, 3, 4, 5])) - await expectRejects(t, () => IdMapIndex.load(file), 'corrupt tvim file should be rejected') + expectThrows(t, () => IdMapIndex.load(file), 'corrupt tvim file should throw') } finally { if (fs.existsSync(file)) fs.unlinkSync(file) } From 14f4f40416ee061da5295041a1d78ead1c48ab84 Mon Sep 17 00:00:00 2001 From: Nidhin Date: Thu, 16 Jul 2026 22:40:25 +0530 Subject: [PATCH 09/11] fix[api]: prevent direct IdMapIndexFilter construction --- packages/embed-llamacpp/idMapIndex.d.ts | 2 ++ packages/embed-llamacpp/idMapIndex.js | 4 ++++ .../embed-llamacpp/test/integration/id-map-index.test.js | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/packages/embed-llamacpp/idMapIndex.d.ts b/packages/embed-llamacpp/idMapIndex.d.ts index c449b57547..74a676a360 100644 --- a/packages/embed-llamacpp/idMapIndex.d.ts +++ b/packages/embed-llamacpp/idMapIndex.d.ts @@ -17,6 +17,8 @@ export interface IdMapIndexSearchResult { } export class IdMapIndexFilter { + private constructor() + /** Search with the prepared allowlist. */ search(queries: Float32Array, k: number): IdMapIndexSearchResult diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js index 703550122f..a466f31a61 100644 --- a/packages/embed-llamacpp/idMapIndex.js +++ b/packages/embed-llamacpp/idMapIndex.js @@ -32,6 +32,10 @@ function ensureFilterHandle(self) { } class IdMapIndexFilter { + constructor() { + throw new TypeError('IdMapIndexFilter instances must be created by IdMapIndex.prepareFilter()') + } + search(queries, k) { if (!(queries instanceof Float32Array)) { throw new TypeError('IdMapIndexFilter.search: queries must be a Float32Array') diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js index 31b180d284..6b4621ef14 100644 --- a/packages/embed-llamacpp/test/integration/id-map-index.test.js +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -270,6 +270,12 @@ test('IdMapIndex: prepared filters are reusable and invalidated by mutation', (t const idx = new IdMapIndex({ dim: 2, bitWidth: 4 }) let filter = null try { + expectThrows( + t, + () => new IdMapIndex.IdMapIndexFilter(), + 'direct prepared-filter construction should throw' + ) + idx.addWithIds(new Float32Array([1, 0, 0, 1, 0.5, 0.5]), new BigUint64Array([11n, 22n, 33n])) filter = idx.prepareFilter(new BigUint64Array([22n, 33n])) From e791f30b7bf9dc91996458b05cd9dae0737ca35b Mon Sep 17 00:00:00 2001 From: Nidhin Date: Thu, 16 Jul 2026 22:57:20 +0530 Subject: [PATCH 10/11] fix[api]: reject oversized IdMapIndex numeric arguments --- .../src/js-interface/vector-index-binding.cpp | 28 +++++++++--- packages/embed-llamacpp/idMapIndex.js | 41 +++++++++++------- .../test/integration/id-map-index.test.js | 43 +++++++++++++++++++ 3 files changed, 90 insertions(+), 22 deletions(-) diff --git a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp index 89e7c143e8..ba4a012c0c 100644 --- a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -11,6 +11,7 @@ // importing the addon does not boot fabric's LLM backend. The same .bare // binary carries both class surfaces; the JS side decides which to construct. +#include #include #include #include @@ -170,16 +171,23 @@ VectorIndexFilter* unwrap_filter(js_env_t* env, js_value_t* handle) { return external->filter; } -// Read a JS object property and parse as int32. On failure, throws and -// returns the provided default; caller should check pending exception via -// js_is_exception_pending or by returning nullptr from the function. +// Read a JS object property and parse it as int32. Returns false if the +// property is missing, non-numeric, fractional, or outside int32 range. bool read_int_prop( js_env_t* env, js_value_t* obj, const char* name, int32_t* out) { js_value_t* val = nullptr; if (js_get_named_property(env, obj, name, &val) != 0) { return false; } - return js_get_value_int32(env, val, out) == 0; + double raw = 0.0; + if (js_get_value_double(env, val, &raw) != 0 || !std::isfinite(raw) || + raw != std::trunc(raw) || + raw < static_cast(std::numeric_limits::min()) || + raw > static_cast(std::numeric_limits::max())) { + return false; + } + *out = static_cast(raw); + return true; } void throw_status(js_env_t* env, int code) { @@ -257,22 +265,30 @@ bool read_biguint64_array( bool read_positive_int32( js_env_t* env, js_value_t* value, const char* name, int32_t* out) { - if (js_get_value_int32(env, value, out) != 0 || *out <= 0) { + double raw = 0.0; + if (js_get_value_double(env, value, &raw) != 0 || !std::isfinite(raw) || + raw != std::trunc(raw) || raw <= 0.0 || + raw > static_cast(std::numeric_limits::max())) { const std::string message = std::string(name) + " must be a positive int"; js_throw_type_error(env, "InvalidArgument", message.c_str()); return false; } + *out = static_cast(raw); return true; } bool read_nonnegative_int32( js_env_t* env, js_value_t* value, const char* name, int32_t* out) { - if (js_get_value_int32(env, value, out) != 0 || *out < 0) { + double raw = 0.0; + if (js_get_value_double(env, value, &raw) != 0 || !std::isfinite(raw) || + raw != std::trunc(raw) || raw < 0.0 || + raw > static_cast(std::numeric_limits::max())) { const std::string message = std::string(name) + " must be a non-negative int"; js_throw_type_error(env, "InvalidArgument", message.c_str()); return false; } + *out = static_cast(raw); return true; } diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js index a466f31a61..9b00133135 100644 --- a/packages/embed-llamacpp/idMapIndex.js +++ b/packages/embed-llamacpp/idMapIndex.js @@ -16,6 +16,7 @@ const binding = require('./binding') const HANDLE = Symbol('IdMapIndex.handle') const FILTER_HANDLE = Symbol('IdMapIndexFilter.handle') const FILTER_OWNER = Symbol('IdMapIndexFilter.owner') +const INT32_MAX = 0x7fffffff function ensureHandle(self) { if (self[HANDLE] === null || self[HANDLE] === undefined) { @@ -31,6 +32,14 @@ function ensureFilterHandle(self) { return self[FILTER_HANDLE] } +function isPositiveInt32(value) { + return Number.isInteger(value) && value > 0 && value <= INT32_MAX +} + +function isNonNegativeInt32(value) { + return Number.isInteger(value) && value >= 0 && value <= INT32_MAX +} + class IdMapIndexFilter { constructor() { throw new TypeError('IdMapIndexFilter instances must be created by IdMapIndex.prepareFilter()') @@ -40,8 +49,8 @@ class IdMapIndexFilter { if (!(queries instanceof Float32Array)) { throw new TypeError('IdMapIndexFilter.search: queries must be a Float32Array') } - if (!Number.isInteger(k) || k <= 0) { - throw new TypeError('IdMapIndexFilter.search: k must be a positive integer') + if (!isPositiveInt32(k)) { + throw new TypeError('IdMapIndexFilter.search: k must be a positive int32') } const filterHandle = ensureFilterHandle(this) return binding.idx_search_prepared_filtered( @@ -74,8 +83,8 @@ class IdMapIndex { * @param {4|8|32} [opts.bitWidth=8] - 4 = q4, 8 = q8, 32 = f32 storage */ constructor({ dim, bitWidth = 8 } = {}) { - if (!Number.isInteger(dim) || dim <= 0) { - throw new TypeError('IdMapIndex: dim must be a positive integer') + if (!isPositiveInt32(dim)) { + throw new TypeError('IdMapIndex: dim must be a positive int32') } if (bitWidth !== 4 && bitWidth !== 8 && bitWidth !== 32) { throw new TypeError('IdMapIndex: bitWidth must be 4, 8, or 32') @@ -192,8 +201,8 @@ class IdMapIndex { if (!(queries instanceof Float32Array)) { throw new TypeError('search: queries must be a Float32Array') } - if (!Number.isInteger(k) || k <= 0) { - throw new TypeError('search: k must be a positive integer') + if (!isPositiveInt32(k)) { + throw new TypeError('search: k must be a positive int32') } return binding.idx_search(ensureHandle(this), queries, k) } @@ -210,8 +219,8 @@ class IdMapIndex { if (!(queries instanceof Float32Array)) { throw new TypeError('searchFiltered: queries must be a Float32Array') } - if (!Number.isInteger(k) || k <= 0) { - throw new TypeError('searchFiltered: k must be a positive integer') + if (!isPositiveInt32(k)) { + throw new TypeError('searchFiltered: k must be a positive int32') } if (!(allowedIds instanceof BigUint64Array)) { throw new TypeError('searchFiltered: allowedIds must be a BigUint64Array') @@ -241,11 +250,11 @@ class IdMapIndex { * @param {number} [nIter=0] */ buildIvf(nLists, nIter = 0) { - if (!Number.isInteger(nLists) || nLists <= 0) { - throw new TypeError('buildIvf: nLists must be a positive integer') + if (!isPositiveInt32(nLists)) { + throw new TypeError('buildIvf: nLists must be a positive int32') } - if (!Number.isInteger(nIter) || nIter < 0) { - throw new TypeError('buildIvf: nIter must be a non-negative integer') + if (!isNonNegativeInt32(nIter)) { + throw new TypeError('buildIvf: nIter must be a non-negative int32') } binding.idx_build_ivf(ensureHandle(this), nLists, nIter) } @@ -262,11 +271,11 @@ class IdMapIndex { if (!(queries instanceof Float32Array)) { throw new TypeError('searchIvf: queries must be a Float32Array') } - if (!Number.isInteger(k) || k <= 0) { - throw new TypeError('searchIvf: k must be a positive integer') + if (!isPositiveInt32(k)) { + throw new TypeError('searchIvf: k must be a positive int32') } - if (!Number.isInteger(nProbe) || nProbe <= 0) { - throw new TypeError('searchIvf: nProbe must be a positive integer') + if (!isPositiveInt32(nProbe)) { + throw new TypeError('searchIvf: nProbe must be a positive int32') } return binding.idx_search_ivf(ensureHandle(this), queries, k, nProbe) } diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js index 6b4621ef14..d278b0b6d8 100644 --- a/packages/embed-llamacpp/test/integration/id-map-index.test.js +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -206,6 +206,49 @@ test('IdMapIndex: validates production bit widths', (t) => { ) }) +test('IdMapIndex: rejects numeric arguments outside int32 range', (t) => { + const tooLarge = 0x80000000 + expectThrows(t, () => new IdMapIndex({ dim: tooLarge }), 'oversized dim should be rejected') + + const idx = new IdMapIndex({ dim: 2 }) + let filter = null + try { + idx.addWithIds(new Float32Array([1, 0]), new BigUint64Array([1n])) + filter = idx.prepareFilter(new BigUint64Array([1n])) + + expectThrows( + t, + () => idx.search(new Float32Array([1, 0]), tooLarge), + 'oversized k should be rejected' + ) + expectThrows( + t, + () => idx.searchFiltered(new Float32Array([1, 0]), tooLarge, new BigUint64Array([1n])), + 'oversized filtered k should be rejected' + ) + expectThrows( + t, + () => filter.search(new Float32Array([1, 0]), tooLarge), + 'oversized prepared-filter k should be rejected' + ) + expectThrows(t, () => idx.buildIvf(tooLarge, 0), 'oversized nLists should be rejected') + expectThrows(t, () => idx.buildIvf(1, tooLarge), 'oversized nIter should be rejected') + expectThrows( + t, + () => idx.searchIvf(new Float32Array([1, 0]), tooLarge, 1), + 'oversized IVF k should be rejected' + ) + expectThrows( + t, + () => idx.searchIvf(new Float32Array([1, 0]), 1, tooLarge), + 'oversized nProbe should be rejected' + ) + } finally { + if (filter !== null) filter.dispose() + idx.dispose() + } +}) + test('IdMapIndex: load APIs throw synchronously for invalid input', (t) => { expectThrows(t, () => IdMapIndex.load(''), 'load invalid path throws') expectThrows(t, () => IdMapIndex.loadMmap(''), 'loadMmap invalid path throws') From 04e48fa2a8569713d35f7b328dc2851047101272 Mon Sep 17 00:00:00 2001 From: Nidhin Date: Thu, 16 Jul 2026 23:06:18 +0530 Subject: [PATCH 11/11] fix[api]: align IdMapIndex exports and defaults --- .../addon/src/js-interface/vector-index-binding.cpp | 4 ++-- packages/embed-llamacpp/package.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp index ba4a012c0c..f06fa6845c 100644 --- a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -480,12 +480,12 @@ js_value_t* idx_create(js_env_t* env, js_callback_info_t* info) { return nullptr; } int32_t dim = 0; - int32_t bit_width = 32; + int32_t bit_width = 8; if (!read_int_prop(env, argv[0], "dim", &dim)) { js_throw_type_error(env, "InvalidArgument", "missing or invalid `dim`"); return nullptr; } - // bitWidth optional; default to 32 if missing. + // bitWidth optional; keep the native fallback aligned with the JS wrapper. (void)read_int_prop(env, argv[0], "bitWidth", &bit_width); VectorIndex* idx = nullptr; diff --git a/packages/embed-llamacpp/package.json b/packages/embed-llamacpp/package.json index c30cd182dc..8cca9c5dc0 100644 --- a/packages/embed-llamacpp/package.json +++ b/packages/embed-llamacpp/package.json @@ -86,6 +86,7 @@ "types": "./idMapIndex.d.ts", "default": "./idMapIndex.js" }, + "./idMapIndex.js": "./idMapIndex.js", "./addonLogging": { "types": "./addonLogging.d.ts", "default": "./addonLogging.js"