diff --git a/packages/embed-llamacpp/CMakeLists.txt b/packages/embed-llamacpp/CMakeLists.txt index 39aa2b7d1f..b55c21e0ab 100644 --- a/packages/embed-llamacpp/CMakeLists.txt +++ b/packages/embed-llamacpp/CMakeLists.txt @@ -17,6 +17,20 @@ 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). 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 +# `./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 +82,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 +90,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 +98,33 @@ target_include_directories( ${QVAC_LIB_INFERENCE_ADDON_CPP_INCLUDE_DIRS} ${PROJECT_SOURCE_DIR}/addon/src ) +# 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(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 + 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::llama") + add_library(llama::common ALIAS llama-common) + endif() +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..f06fa6845c --- /dev/null +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -0,0 +1,1317 @@ +// 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 + +#include + +#include "../addon/VectorIndexErrors.hpp" +#include "../model-interface/VectorIndex.hpp" + +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* 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, holder, finalize_vector_index, nullptr, &external) != 0) { + 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 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 external->filter; +} + +// 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; + } + 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) { + 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; + } + + 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; + } + 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) { + 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) { + 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; +} + +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; + } + 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; + 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, + 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; +} + +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_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 +// --------------------------------------------------------------------------- + +// 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 = 8; + if (!read_int_prop(env, argv[0], "dim", &dim)) { + js_throw_type_error(env, "InvalidArgument", "missing or invalid `dim`"); + return nullptr; + } + // bitWidth optional; keep the native fallback aligned with the JS wrapper. + (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; + } + 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_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; + 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; + } + + VectorBatchInput batch; + if (!read_vector_batch(env, idx, argv[1], argv[2], &batch)) { + return nullptr; + } + + const int rc = idx->add(batch.vectors, batch.n, batch.ids); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// 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 < 4) { + 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; + } + + 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], + "deltaPath must be a string", + "failed to read delta path", + &delta_path)) { + return nullptr; + } + + const int rc = idx->addLogged(batch.vectors, batch.n, batch.ids, delta_path); + 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; + } + + SearchInput input; + if (!read_search_input(env, idx, argv[1], argv[2], &input)) { + return nullptr; + } + + const int rc = idx->search( + 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, 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}; + 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)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { + return nullptr; + } + + SearchInput input; + if (!read_search_input(env, idx, argv[1], argv[2], &input)) { + return nullptr; + } + + const uint64_t* allowed_ids = nullptr; + size_t alen = 0; + if (!read_biguint64_array(env, argv[3], "allowedIds", &allowed_ids, &alen)) { + return nullptr; + } + if (alen > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many allowed ids"); + return nullptr; + } + + const int rc = idx->searchFiltered( + input.queries, + input.m, + input.k, + alen == 0 ? nullptr : allowed_ids, + static_cast(alen), + static_cast(input.output.scoresData), + static_cast(input.output.idsData)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + 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}; + 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; + } + + const uint64_t* allowed_ids = nullptr; + size_t alen = 0; + if (!read_biguint64_array(env, argv[1], "allowedIds", &allowed_ids, &alen)) { + 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 : allowed_ids, 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; + } + + SearchInput input; + if (!read_search_input(env, idx, argv[2], argv[3], &input)) { + return nullptr; + } + + const int rc = idx->searchPreparedFiltered( + *filter, + 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, 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}; + 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 (!read_positive_int32(env, argv[1], "nLists", &n_lists)) { + return nullptr; + } + + int32_t n_iter = 0; + if (!read_nonnegative_int32(env, argv[2], "nIter", &n_iter)) { + 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; + } + + SearchInput input; + if (!read_search_input(env, idx, argv[1], argv[2], &input)) { + return nullptr; + } + + int32_t n_probe = 0; + if (!read_positive_int32(env, argv[3], "nProbe", &n_probe)) { + return nullptr; + } + + const int rc = idx->searchIvf( + input.queries, + input.m, + input.k, + n_probe, + static_cast(input.output.scoresData), + static_cast(input.output.idsData)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + 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}; + 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_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; + 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 (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}; + 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; +} + +// 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) { + 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_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>)); +#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..3f6763d3d1 --- /dev/null +++ b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp @@ -0,0 +1,200 @@ +#include "VectorIndex.hpp" + +#include +#include + +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) { + 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::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, + int k, + float* outScores, + uint64_t* outIds) const noexcept { + return ggml_vec_index_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_); +} + +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..9f81a52d9e --- /dev/null +++ b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp @@ -0,0 +1,147 @@ +#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 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 + // 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; + + 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, + int n_q, + int k, + 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; + [[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/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..b433ccd2f0 --- /dev/null +++ b/packages/embed-llamacpp/benchmarks/performance/id-map-index-turbovec-cpu.js @@ -0,0 +1,418 @@ +'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 } +} + +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 } +} + +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 = measureSync(() => IdMapIndex.load(snapshot)) + loaded = load.value + const mmapLoad = measureSync(() => 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) filter.dispose() + if (loaded !== null) loaded.dispose() + if (mmap !== null) mmap.dispose() + if (idx !== null) 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` +} + +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(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}`) +} + +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 new file mode 100644 index 0000000000..74a676a360 --- /dev/null +++ b/packages/embed-llamacpp/idMapIndex.d.ts @@ -0,0 +1,110 @@ +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 { + private constructor() + + /** Search with the prepared allowlist. */ + search(queries: Float32Array, k: number): IdMapIndexSearchResult + + dispose(): void +} + +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()`. Synchronous; may block for large indexes. */ + static load(path: string): IdMapIndex + + /** 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. Synchronous; may block for large indexes. */ + static loadWithDelta( + snapshotPath: string, + deltaPath: string + ): IdMapIndex + + /** + * Insert `n` vectors with stable external ids. Throws on duplicate id or + * 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. + * `UINT64_MAX` is reserved for search result padding and cannot be inserted. + */ + 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(): void +} + +export { IdMapIndex } diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js new file mode 100644 index 0000000000..9b00133135 --- /dev/null +++ b/packages/embed-llamacpp/idMapIndex.js @@ -0,0 +1,384 @@ +'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 +// 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) +// 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') +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) { + throw new Error('IdMapIndex has been disposed') + } + 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] +} + +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()') + } + + search(queries, k) { + if (!(queries instanceof Float32Array)) { + throw new TypeError('IdMapIndexFilter.search: queries must be a Float32Array') + } + if (!isPositiveInt32(k)) { + throw new TypeError('IdMapIndexFilter.search: k must be a positive int32') + } + 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 + } + } +} + +/** + * 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 {4|8|32} [opts.bitWidth=8] - 4 = q4, 8 = q8, 32 = f32 storage + */ + constructor({ dim, bitWidth = 8 } = {}) { + 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') + } + this[HANDLE] = binding.idx_create({ dim, bitWidth }) + } + + /** + * 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 {IdMapIndex} + */ + 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 + } + + /** + * 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 {IdMapIndex} + */ + 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 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 {IdMapIndex} + */ + 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 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 (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) + } + + /** + * 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 + * 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 (!isPositiveInt32(k)) { + throw new TypeError('search: k must be a positive int32') + } + 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 (!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') + } + 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 (!isPositiveInt32(nLists)) { + throw new TypeError('buildIvf: nLists must be a positive int32') + } + if (!isNonNegativeInt32(nIter)) { + throw new TypeError('buildIvf: nIter must be a non-negative int32') + } + 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 (!isPositiveInt32(k)) { + throw new TypeError('searchIvf: k must be a positive int32') + } + if (!isPositiveInt32(nProbe)) { + throw new TypeError('searchIvf: nProbe must be a positive int32') + } + return binding.idx_search_ivf(ensureHandle(this), queries, k, nProbe) + } + + /** + * 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) + } + + /** + * 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} + */ + contains(id) { + if (typeof id !== 'bigint') { + throw new TypeError('contains: id must be a bigint') + } + return binding.idx_contains(ensureHandle(this), id) + } + + /** Placeholder for future cache warming. */ + prepare() { + binding.idx_prepare(ensureHandle(this)) + } + + /** + * Persist the index to disk in the checksummed .tvim v2 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) + } + + /** + * 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)) + } + get dim() { + return binding.idx_dim(ensureHandle(this)) + } + get bitWidth() { + return binding.idx_bit_width(ensureHandle(this)) + } + + /** + * Free native resources and mark the instance as unusable. The external's + * 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 + } + } +} + +IdMapIndex.IdMapIndex = IdMapIndex +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 eac46afd8c..e3f5a23c6e 100644 --- a/packages/embed-llamacpp/index.d.ts +++ b/packages/embed-llamacpp/index.d.ts @@ -87,3 +87,10 @@ 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 + +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 cab57eeb8d..4ead989eef 100644 --- a/packages/embed-llamacpp/index.js +++ b/packages/embed-llamacpp/index.js @@ -216,3 +216,21 @@ class GGMLBert { module.exports = GGMLBert module.exports.pickPrimaryGgufPath = pickPrimaryGgufPath + +// 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 + } +}) diff --git a/packages/embed-llamacpp/package.json b/packages/embed-llamacpp/package.json index d7c4f15ab0..8cca9c5dc0 100644 --- a/packages/embed-llamacpp/package.json +++ b/packages/embed-llamacpp/package.json @@ -41,11 +41,13 @@ "files": [ "binding.js", "index.js", + "idMapIndex.js", "addon.js", "addonLogging.js", "addonLogging.d.ts", "prebuilds", "index.d.ts", + "idMapIndex.d.ts", "LICENSE", "NOTICE" ], @@ -80,6 +82,11 @@ "types": "./index.d.ts", "default": "./index.js" }, + "./idMapIndex": { + "types": "./idMapIndex.d.ts", + "default": "./idMapIndex.js" + }, + "./idMapIndex.js": "./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..d278b0b6d8 --- /dev/null +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -0,0 +1,565 @@ +'use strict' +// +// 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') +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 +const UINT64_MAX = (1n << 64n) - 1n + +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`) +} + +function tmpDeltaPath(name) { + return tmpPath(name).replace(/\.tvim$/, '.tvid') +} + +function expectThrows(t, fn, message) { + try { + 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') + 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') +} + +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') +} + +function runRoundTrip(t, bitWidth) { + const idx = new IdMapIndex({ dim: DIM, bitWidth }) + + 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 vectors inserted (${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 f32, q8, and q4 storage. + for (let i = 0; i < N; i++) { + 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 (${bitWidth})`) + t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5, `score≈1.0 for self-match (${bitWidth})`) + } + + { + 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], UINT64_MAX, `sentinel id at tail slot ${i} (${bitWidth})`) + t.ok(out.scores[i] < -3e38, `sentinel score at tail slot ${i} (${bitWidth})`) + } + } + + { + const dupIds = new BigUint64Array([ids[0]]) + expectThrows( + t, + () => idx.addWithIds(unitVec(0), dupIds), + `duplicate add should throw (${bitWidth})` + ) + t.is(idx.length, N, `length unchanged after rejected dup add (${bitWidth})`) + } + + { + const removed = idx.remove(ids[2]) + 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} (${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}`) + try { + idx.write(file) + assertTvimV2Header(t, file, bitWidth) + idx.dispose() + + 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})`) + 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 (${bitWidth})`) + t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5, `score≈1.0 after reload (${bitWidth})`) + + 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: q4 add + search + remove + persistence round-trip', (t) => { + runRoundTrip(t, 4) +}) + +test('IdMapIndex: q8 add + search + remove + persistence round-trip', (t) => { + runRoundTrip(t, 8) +}) + +test('IdMapIndex: f32 add + search + remove + persistence round-trip', (t) => { + runRoundTrip(t, 32) +}) + +test('IdMapIndex: validates production bit widths', (t) => { + expectThrows( + t, + () => new IdMapIndex({ dim: DIM, bitWidth: 16 }), + 'bitWidth 16 should be rejected' + ) +}) + +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') + expectThrows(t, () => IdMapIndex.loadWithDelta('', ''), 'loadWithDelta invalid paths throw') +}) + +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) => { + // 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') + 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() +}) + +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', (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])) + + 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' + ) + + filter.dispose() + filter.dispose() + expectThrows( + t, + () => filter.search(new Float32Array([1, 0]), 1), + 'disposed prepared filter should throw' + ) + filter = null + } finally { + if (filter !== null) filter.dispose() + 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', (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 = 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' + ) + replayed.dispose() + replayed = null + + idx.compactDelta(snapshot, delta) + assertTvimV2Header(t, snapshot, 4) + assertTvidHeader(t, delta) + idx.dispose() + idx = null + + 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) 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', (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) + idx.dispose() + idx = null + + t.absent(fs.existsSync(delta), 'delta log does not exist before load') + 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) 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', (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) + idx.dispose() + idx = null + + fs.writeFileSync(delta, new Uint8Array([0, 1, 2, 3, 4, 5])) + expectThrows( + t, + () => IdMapIndex.loadWithDelta(snapshot, delta), + 'corrupt delta log should throw' + ) + } finally { + if (idx !== null) 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( + 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: mmap load is searchable and read-only', (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) + idx.dispose() + + 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') + 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) filter.dispose() + if (mmap !== null) mmap.dispose() + if (fs.existsSync(file)) fs.unlinkSync(file) + } +}) + +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])) + 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', (t) => { + const file = tmpPath('id-map-index-corrupt') + try { + fs.writeFileSync(file, new Uint8Array([0, 1, 2, 3, 4, 5])) + expectThrows(t, () => IdMapIndex.load(file), 'corrupt tvim file should throw') + } finally { + if (fs.existsSync(file)) fs.unlinkSync(file) + } +}) 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.json b/packages/embed-llamacpp/vcpkg.json index 61cb14595c..50c270b3de 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 (>=9341.1.6) 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..d142f321fb --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake @@ -0,0 +1,52 @@ +# 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. + 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_ERROR "Could not find NDK host directory for ${host_system_name_lower}") + endif() + + 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) + 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_ERROR "Could not extract VK_HEADER_VERSION from ${vulkan_core_h}") + 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_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 new file mode 100644 index 0000000000..353d9d09ce --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake @@ -0,0 +1,229 @@ +# LOCAL OVERLAY for qvac-fabric (turbovec). +# +# Pinned by default to a commit on +# https://github.com/dev-nid/qvac-fabric-llm.cpp.git +# (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 +# 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 +# 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 "5b49384cb2d6f32a1f19efc919e95a37e478b37d") # turbovec-cpu +set(FABRIC_GH_HEAD_REF "turbovec-cpu") +set(FABRIC_GH_SHA512 + "b8516eaac76207630f0c15908ffd78e3fe55cf8dbc9316f360402a187e07da2d034df6a5058bc0b965dfb1800209ac0a9d8293f07730861d89ac2d936dfdcc32") + +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() + resolve_vulkan_headers_sha512("${vulkan_version}" vulkan_headers_sha512) + message(STATUS "Using Vulkan C++ wrappers from version: ${vulkan_version}") + 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 "${VULKAN_HEADERS_ARCHIVE}" + 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_BUILD_APP=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. +set(VCPKG_POLICY_ALLOW_EMPTY_FOLDERS enabled) +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..26ef3abf22 --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json @@ -0,0 +1,33 @@ +{ + "name": "qvac-fabric", + "version": "9341.1.6", + "port-version": 9999, + "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": [ + { + "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." + } + } +}