From 9c0ed291e39d24374c401ec5169e58ba298a3597 Mon Sep 17 00:00:00 2001 From: Sergey Vershinin Date: Thu, 28 May 2026 14:10:39 +0300 Subject: [PATCH 1/7] Make: Bundle NumKong runtime with C# native artifacts Co-authored-by: Sergey Vershinin <4435150+sergey-v9@users.noreply.github.com> --- .github/workflows/prerelease.yml | 14 +++++++++----- .github/workflows/release.yml | 22 ++++++++++++++++++---- CMakeLists.txt | 28 ++++++++++++++++++++++++++++ CONTRIBUTING.md | 3 +++ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index afcdf874..b61f37ae 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -137,6 +137,7 @@ jobs: run: | mkdir -p "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" cp "${{ github.workspace }}/build_artifacts/libusearch_c.so" "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" + cp "${{ github.workspace }}/build_artifacts/numkong/libnumkong.so" "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" dotnet test -c Debug --logger "console;verbosity=detailed" shell: bash working-directory: ${{ github.workspace }}/csharp @@ -218,6 +219,7 @@ jobs: run: | mkdir -p "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" cp "${{ github.workspace }}/build_artifacts/libusearch_c.so" "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" + cp "${{ github.workspace }}/build_artifacts/numkong/libnumkong.so" "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" dotnet test -c Debug --logger "console;verbosity=detailed" shell: bash working-directory: ${{ github.workspace }}/csharp @@ -307,8 +309,8 @@ jobs: # C/C++ - name: Build C/C++ run: | - choco install cmake - cmake -B build_artifacts -D CMAKE_BUILD_TYPE=RelWithDebInfo -D USEARCH_BUILD_TEST_CPP=1 -D USEARCH_BUILD_TEST_C=1 -D USEARCH_BUILD_LIB_C=1 -D USEARCH_BUILD_SQLITE=0 + choco install cmake -y + cmake -B build_artifacts -D CMAKE_BUILD_TYPE=RelWithDebInfo -D USEARCH_BUILD_TEST_CPP=1 -D USEARCH_BUILD_TEST_C=1 -D USEARCH_BUILD_LIB_C=1 -D USEARCH_BUILD_SQLITE=0 -D USEARCH_USE_NUMKONG=1 cmake --build build_artifacts --config RelWithDebInfo - name: Test C++ run: .\build_artifacts\test_cpp.exe @@ -353,11 +355,13 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Test .NET + shell: pwsh run: | - mkdir -p "${{ github.workspace }}\csharp\lib\runtimes\win-x64\native" - cp "${{ github.workspace }}\build_artifacts\libusearch_c.dll" "${{ github.workspace }}\csharp\lib\runtimes\win-x64\native" + $nativeDir = "${{ github.workspace }}\csharp\lib\runtimes\win-x64\native" + New-Item -ItemType Directory -Force -Path $nativeDir | Out-Null + Copy-Item "${{ github.workspace }}\build_artifacts\libusearch_c.dll" -Destination $nativeDir + Copy-Item "${{ github.workspace }}\build_artifacts\numkong.dll" -Destination $nativeDir dotnet test -c Debug --logger "console;verbosity=detailed" - shell: bash working-directory: ${{ github.workspace }}/csharp test_windows_arm: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cde29d93..63ad13e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1136,6 +1136,7 @@ jobs: cmake --build build_artifacts --config Release mkdir -p "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" cp "${{ github.workspace }}/build_artifacts/libusearch_c.so" "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" + cp "${{ github.workspace }}/build_artifacts/numkong/libnumkong.so" "${{ github.workspace }}/csharp/lib/runtimes/linux-x64/native" - name: Build C library for MacOS if: matrix.os == 'macos-15' @@ -1146,15 +1147,19 @@ jobs: cmake --build build_artifacts --config Release mkdir -p "${{ github.workspace }}/csharp/lib/runtimes/osx-arm64/native" cp "${{ github.workspace }}/build_artifacts/libusearch_c.dylib" "${{ github.workspace }}/csharp/lib/runtimes/osx-arm64/native" + cp "${{ github.workspace }}/build_artifacts/numkong/libnumkong.dylib" "${{ github.workspace }}/csharp/lib/runtimes/osx-arm64/native" - name: Build C library for Windows if: matrix.os == 'windows-2022' + shell: pwsh run: | - choco install cmake - cmake -B build_artifacts -DCMAKE_BUILD_TYPE=Release -DUSEARCH_BUILD_TEST_CPP=0 -DUSEARCH_BUILD_TEST_C=0 -DUSEARCH_BUILD_LIB_C=1 -DUSEARCH_USE_OPENMP=0 -DUSEARCH_USE_NUMKONG=0 -DUSEARCH_USE_JEMALLOC=0 + choco install cmake -y + cmake -B build_artifacts -DCMAKE_BUILD_TYPE=Release -DUSEARCH_BUILD_TEST_CPP=0 -DUSEARCH_BUILD_TEST_C=0 -DUSEARCH_BUILD_LIB_C=1 -DUSEARCH_USE_OPENMP=0 -DUSEARCH_USE_NUMKONG=1 -DUSEARCH_USE_JEMALLOC=0 cmake --build build_artifacts --config Release - mkdir -p "${{ github.workspace }}\csharp\lib\runtimes\win-x64\native" - cp "${{ github.workspace }}\build_artifacts\libusearch_c.dll" "${{ github.workspace }}\csharp\lib\runtimes\win-x64\native" + $nativeDir = "${{ github.workspace }}\csharp\lib\runtimes\win-x64\native" + New-Item -ItemType Directory -Force -Path $nativeDir | Out-Null + Copy-Item "${{ github.workspace }}\build_artifacts\libusearch_c.dll" -Destination $nativeDir + Copy-Item "${{ github.workspace }}\build_artifacts\numkong.dll" -Destination $nativeDir - name: Upload Artifacts uses: actions/upload-artifact@v5 @@ -1188,6 +1193,15 @@ jobs: merge-multiple: true path: ${{ env.USEARCH_LIBS }} + - name: Verify native package inputs + run: | + test -f "${{ env.USEARCH_LIBS }}/runtimes/linux-x64/native/libusearch_c.so" + test -f "${{ env.USEARCH_LIBS }}/runtimes/linux-x64/native/libnumkong.so" + test -f "${{ env.USEARCH_LIBS }}/runtimes/osx-arm64/native/libusearch_c.dylib" + test -f "${{ env.USEARCH_LIBS }}/runtimes/osx-arm64/native/libnumkong.dylib" + test -f "${{ env.USEARCH_LIBS }}/runtimes/win-x64/native/libusearch_c.dll" + test -f "${{ env.USEARCH_LIBS }}/runtimes/win-x64/native/numkong.dll" + - name: Setup .NET uses: actions/setup-dotnet@v5 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index 28523fdb..804a9e66 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -379,6 +379,21 @@ function (setup_target TARGET_NAME) if (USEARCH_USE_NUMKONG AND TARGET nk_shared) target_compile_definitions(${TARGET_NAME} PRIVATE "NK_DYNAMIC_DISPATCH=1") target_link_libraries(${TARGET_NAME} PRIVATE nk_shared) + + # Keep NumKong discoverable both in the build tree (`build/numkong`) and + # after packaging it next to `libusearch_c` in language bindings. + if (APPLE) + set(_USEARCH_NUMKONG_RPATH "@loader_path;@loader_path/numkong") + elseif (UNIX AND NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten") + set(_USEARCH_NUMKONG_RPATH "$ORIGIN;$ORIGIN/numkong") + endif () + + if (DEFINED _USEARCH_NUMKONG_RPATH) + set_target_properties( + ${TARGET_NAME} PROPERTIES BUILD_RPATH "${_USEARCH_NUMKONG_RPATH}" + INSTALL_RPATH "${_USEARCH_NUMKONG_RPATH}" + ) + endif () endif () endfunction () @@ -394,6 +409,19 @@ if (USEARCH_USE_NUMKONG) if (TARGET nk_shared) set_target_properties(nk_shared PROPERTIES ENABLE_EXPORTS ON) + + # Windows has no RPATH equivalent, so the dynamic loader only finds + # NumKong reliably when it sits next to the binaries that link it. + if (WIN32) + set_target_properties( + nk_shared + PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}" + ) + endif () endif () endif () diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 626bda30..6d39d99a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -484,6 +484,7 @@ Then, on Windows, copy the library to the CSharp project and run the tests: ```sh mkdir -p ".\csharp\lib\runtimes\win-x64\native" cp ".\build_artifacts\libusearch_c.dll" ".\csharp\lib\runtimes\win-x64\native" +cp ".\build_artifacts\numkong.dll" ".\csharp\lib\runtimes\win-x64\native" cd csharp dotnet test -c Debug --logger "console;verbosity=detailed" dotnet test -c Release @@ -494,8 +495,10 @@ On Linux, the process is similar: ```sh mkdir -p "csharp/lib/runtimes/linux-x64/native" # for x86 cp "build_artifacts/libusearch_c.so" "csharp/lib/runtimes/linux-x64/native" # for x86 +cp "build_artifacts/numkong/libnumkong.so" "csharp/lib/runtimes/linux-x64/native" # for x86 mkdir -p "csharp/lib/runtimes/linux-arm64/native" # for ARM cp "build_artifacts/libusearch_c.so" "csharp/lib/runtimes/linux-arm64/native" # for ARM +cp "build_artifacts/numkong/libnumkong.so" "csharp/lib/runtimes/linux-arm64/native" # for ARM cd csharp dotnet test -c Debug --logger "console;verbosity=detailed" dotnet test -c Release From 72406528bdbef67def8d39622acf75ddeecb06ef Mon Sep 17 00:00:00 2001 From: Evgeniy Peshkov Date: Fri, 10 Jul 2026 14:48:35 +0100 Subject: [PATCH 2/7] Make: Build JavaScript Windows x64 prebuilds (#774) The `build_javascript` job exported `CC=gcc`/`CXX=g++` for every OS. On Windows `node-gyp` ignores both and builds with MSVC, but NumKong's ISA probe reads the compiler from `$CC` while still emitting MSVC flag syntax, so it shells out to `gcc /c /arch:AVX2 ... /nologo`. Every probe fails, the failures are swallowed, and `nk_probes.h` lands with all 39 `NK_TARGET_*` set to zero. The addon still builds and passes the test suite -- dynamic dispatch just has no kernels to choose from -- so the result is a scalar-only binary shipped to every Windows npm user. Scope the variables to the Linux step and let the probe fall back to `cl.exe`, which enables Haswell and Skylake. Co-authored-by: Evgeniy Peshkov <2716874+GraDea@users.noreply.github.com> Co-authored-by: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com> --- .github/workflows/release.yml | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63ad13e2..854bc9b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -813,19 +813,9 @@ jobs: strategy: fail-fast: false matrix: - arch: [x64, x86] - # Windows pre-build is not working - # - windows-latest - os: [macos-14, ubuntu-24.04] - exclude: - - arch: x86 - os: macos-14 - - arch: x86 - os: ubuntu-24.04 + arch: [x64] + os: [macos-14, ubuntu-24.04, windows-2022] runs-on: ${{ matrix.os }} - env: - CC: gcc - CXX: g++ steps: - name: Checkout the latest code @@ -852,7 +842,16 @@ jobs: run: | npm install --ignore-scripts - run: npm run prebuild-single - if: matrix.os != 'macos-14' + env: + CC: gcc + CXX: g++ + if: matrix.os == 'ubuntu-24.04' + # Leave CC/CXX unset on Windows. node-gyp builds with MSVC regardless, + # but NumKong's ISA probe takes the compiler from $CC while still + # passing MSVC flag syntax, so a `gcc` here fails every probe and + # silently yields a scalar-only binary with no SIMD kernels. + - run: npm run prebuild-single + if: matrix.os == 'windows-2022' - run: npm run prebuild-darwin-x64+arm64 env: CC: clang From 749d03bd6e6287e05a4bf3d717ebf9cfc3bb8b7e Mon Sep 17 00:00:00 2001 From: tang donghai Date: Fri, 10 Jul 2026 22:26:07 +0800 Subject: [PATCH 3/7] Add: Rust compact binding (#771) Co-Authored-By: Tang Donghai <72755185+tang-hi@users.noreply.github.com> --- rust/lib.cpp | 5 +++++ rust/lib.hpp | 1 + rust/lib.rs | 11 +++++++++++ 3 files changed, 17 insertions(+) diff --git a/rust/lib.cpp b/rust/lib.cpp index 5ca243c5..2fde17df 100644 --- a/rust/lib.cpp +++ b/rust/lib.cpp @@ -252,6 +252,11 @@ void NativeIndex::view(rust::Str path) const { index_->view(memory_mapped_file_t(std::string(path).c_str())).error.raise(); } +void NativeIndex::compact() const { + auto result = index_->compact(); + result.error.raise(); +} + void NativeIndex::reset() const { index_->reset(); } size_t NativeIndex::memory_usage() const { return index_->memory_usage(); } diff --git a/rust/lib.hpp b/rust/lib.hpp index 8f31f915..3f6ed211 100644 --- a/rust/lib.hpp +++ b/rust/lib.hpp @@ -118,6 +118,7 @@ class NativeIndex { void save(rust::Str path) const; void load(rust::Str path) const; void view(rust::Str path) const; + void compact() const; void reset() const; size_t memory_usage() const; MemoryStats memory_stats() const; diff --git a/rust/lib.rs b/rust/lib.rs index 95807483..5a44bba2 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -541,6 +541,7 @@ pub mod ffi { pub fn load(self: &NativeIndex, path: &str) -> Result<()>; pub fn view(self: &NativeIndex, path: &str) -> Result<()>; pub fn reset(self: &NativeIndex) -> Result<()>; + pub fn compact(self: &NativeIndex) -> Result<()>; pub fn memory_usage(self: &NativeIndex) -> usize; pub fn memory_stats(self: &NativeIndex) -> MemoryStats; pub fn hardware_acceleration(self: &NativeIndex) -> *const c_char; @@ -1715,6 +1716,16 @@ impl Index { self.inner.view(path) } + /// Compacts the index by removing links to deleted entries and rebuilding + /// the internal vector storage layout. + /// + /// This is useful after removals when you want to prune stale graph edges + /// and reduce wasted memory. Compaction is an expensive mutating operation, + /// so avoid running it concurrently with searches or updates on the same index. + pub fn compact(self: &Index) -> Result<(), cxx::Exception> { + self.inner.compact() + } + /// Erases all members from the index, closes files, and returns RAM to OS. pub fn reset(self: &Index) -> Result<(), cxx::Exception> { self.inner.reset() From 9fc3500fc652649df90e476d07fcd7dd0f9f3bb7 Mon Sep 17 00:00:00 2001 From: Chakshu Dhannawat Date: Fri, 10 Jul 2026 23:29:30 +0900 Subject: [PATCH 4/7] Fix: Mix integer key hashes with SplitMix64 (#773) `std::hash` is the identity for integers on libstdc++ and libc++. Our open-addressing tables mask that hash into a power-of-2 slot count and probe linearly until an empty slot, so consecutive keys land in adjacent slots and coalesce into a single contiguous run. Insertions and lookups both scan that run end to end, making the whole build quadratic. Closes #770. Co-Authored-By: Chakshu Dhannawat <65147507+chakshu-dhannawat@users.noreply.github.com> Co-Authored-By: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com> --- include/usearch/index.hpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/include/usearch/index.hpp b/include/usearch/index.hpp index b4e12be1..712ed670 100644 --- a/include/usearch/index.hpp +++ b/include/usearch/index.hpp @@ -1277,6 +1277,31 @@ template struct hash_gt { std::size_t operator()(element_at const& element) const noexcept { return std::hash{}(element); } }; +/** + * @brief SplitMix64 finalizer, used to scatter integer keys before masking. + * + * On libstdc++ and libc++ `std::hash` is the identity for integers. Our open-addressing + * tables mask that hash into a power-of-2 slot count and probe linearly until an @b empty + * slot, so consecutive keys land in adjacent slots and merge into one contiguous run. + * Both insertions and lookups then scan that run end-to-end, which is quadratic overall: + * a dense ascending key range collapses insertion throughput by three orders of magnitude. + * Mixing costs ~20ns per key and is dwarfed by the graph traversal in `add`. + */ +template <> struct hash_gt { + std::size_t operator()(std::uint64_t const& element) const noexcept { + std::uint64_t x = element; + x = (x ^ (x >> 30u)) * 0xBF58476D1CE4E5B9ULL; + x = (x ^ (x >> 27u)) * 0x94D049BB133111EBULL; + return static_cast(x ^ (x >> 31u)); + } +}; + +template <> struct hash_gt { + std::size_t operator()(std::int64_t const& element) const noexcept { + return hash_gt{}(static_cast(element)); + } +}; + template <> struct hash_gt { std::size_t operator()(uint40_t const& element) const noexcept { return std::hash{}(element); } }; From c6d634ceb5071a503b56100da43cfec9a826c6f9 Mon Sep 17 00:00:00 2001 From: Misha Chichvarin Date: Fri, 10 Jul 2026 17:55:08 +0300 Subject: [PATCH 5/7] Fix: Reclaim `slot_lookup_` tombstones under churn (#769) `remove` marks a slot deleted but leaves it populated, so probes walk past it. The resize decision only counted live entries, and under `remove`+`add` churn the live count stays flat, so the table never grew and never rehashed. Tombstones accumulated until no empty slot remained; probes could then no longer terminate, `equal_range` returned `end()` for a key that was still live, and `remove` took the empty-range branch reporting nothing removed. The entry became a ghost: `contains` kept finding it, `remove` silently no-oped, and `size()` drifted above the true live count. Closes #753 Co-Authored-By: Misha Chichvarin <6496186+desertfury@users.noreply.github.com> Co-Authored-By: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com> --- cpp/test.cpp | 70 ++++++++++++++++++ include/usearch/index_plugins.hpp | 118 +++++++++++++++++------------- 2 files changed, 138 insertions(+), 50 deletions(-) diff --git a/cpp/test.cpp b/cpp/test.cpp index 3ea2f86e..530a9015 100644 --- a/cpp/test.cpp +++ b/cpp/test.cpp @@ -1115,6 +1115,72 @@ template void test_strings() { /** * @brief Tests replacing and updating entries in index_dense_gt to ensure consistency after modifications. */ +/** + * @brief Churns a tightly-reserved index, so `slot_lookup_` accumulates tombstones. + * + * Removals leave tombstones that probes must walk past. If they are never reclaimed the + * table runs out of empty slots, probes can no longer terminate, and live keys go missing: + * `remove` reports nothing removed while `contains` still finds the key. Copying such a + * table must rebuild the probe chains, not reproduce the slot layout. + */ +template void test_slot_lookup_churn() { + constexpr std::size_t live_count = 128; + constexpr std::size_t churn_count = live_count * 8; + constexpr std::size_t dimensions = 4; + + using index_punned_t = index_dense_gt; + metric_punned_t metric(dimensions, metric_kind_t::cos_k); + + std::random_device seed_source; + std::mt19937 generator(seed_source()); + std::uniform_real_distribution distribution(0.0, 1.0); + using vector_of_vectors_t = std::vector>; + + vector_of_vectors_t vector_of_vectors(live_count + churn_count); + for (auto& vector : vector_of_vectors) { + vector.resize(dimensions); + std::generate(vector.begin(), vector.end(), [&] { return distribution(generator); }); + } + + index_punned_t index = index_punned_t::make(metric); + + // Reserve tightly, so the churn below exhausts the empty slots + index.reserve(live_count * 3); + + std::vector live_keys; + std::size_t added = 0; + for (; added < live_count; ++added) { + index.add(static_cast(added), vector_of_vectors[added].data()); + live_keys.push_back(static_cast(added)); + } + + // Every iteration frees one slot and immediately reuses it + for (std::size_t idx = 0; idx < churn_count; ++idx, ++added) { + key_at victim = live_keys[idx % live_count]; + expect(index.contains(victim)); + expect_eq(index.remove(victim).completed, 1); + expect(!index.contains(victim)); + index.add(static_cast(added), vector_of_vectors[added].data()); + live_keys[idx % live_count] = static_cast(added); + } + + // Every live key stays reachable and the size never drifts + expect_eq(index.size(), live_count); + for (key_at key : live_keys) + expect(index.contains(key)); + + // Copying a table that still holds tombstones must not strand live keys + expect_eq(index.remove(live_keys.back()).completed, 1); + live_keys.pop_back(); + + auto copy_result = index.copy(); + expect(copy_result); + index_punned_t& copy = copy_result.index; + expect_eq(copy.size(), live_keys.size()); + for (key_at key : live_keys) + expect(copy.contains(key)); +} + template void test_replacing_update() { using vector_key_t = key_at; @@ -1450,6 +1516,10 @@ int main(int, char**) { test_sets(set_size, 20, 30); test_strings(); + std::printf("Testing key lookups under churn\n"); + test_slot_lookup_churn(); + test_slot_lookup_churn(); + test_filtered_search(); test_isolate(); test_load_after_metric_make(); diff --git a/include/usearch/index_plugins.hpp b/include/usearch/index_plugins.hpp index e27eb859..330d2a7d 100644 --- a/include/usearch/index_plugins.hpp +++ b/include/usearch/index_plugins.hpp @@ -3746,6 +3746,8 @@ class flat_hash_multi_set_gt { char* data_ = nullptr; std::size_t buckets_ = 0; std::size_t populated_slots_ = 0; + /// @brief Number of tombstones (slots marked deleted but not yet reclaimed) + std::size_t deleted_slots_ = 0; /// @brief Number of slots std::size_t capacity_slots_ = 0; @@ -3781,6 +3783,34 @@ class flat_hash_multi_set_gt { } } + /** + * @brief Copies every live entry of @p source into the freshly zeroed @p target. + * + * Tombstones are never carried over, so probe chains must be rebuilt from the hash + * rather than reproduced slot-for-slot: a live entry displaced past a tombstone by + * linear probing would otherwise become unreachable once that tombstone reads empty. + * The target holds no tombstones, so an unpopulated slot is always a free slot. + */ + void rehash_into(char* source, std::size_t source_slots, char* target, std::size_t target_slots) const noexcept { + hash_t hasher; + for (std::size_t i = 0; i != source_slots; ++i) { + slot_ref_t source_slot = slot_ref(source, i); + if (!(source_slot.header.populated & source_slot.mask) || (source_slot.header.deleted & source_slot.mask)) + continue; + + std::size_t target_index = hasher(source_slot.element) & (target_slots - 1); + while (true) { + slot_ref_t target_slot = slot_ref(target, target_index); + if (!(target_slot.header.populated & target_slot.mask)) { + new (&target_slot.element) element_t(source_slot.element); + target_slot.header.populated |= target_slot.mask; + break; + } + target_index = (target_index + 1) & (target_slots - 1); + } + } + } + public: std::size_t size() const noexcept { return populated_slots_; } std::size_t capacity() const noexcept { return capacity_slots_ * 2u / 3u; } @@ -3805,22 +3835,15 @@ class flat_hash_multi_set_gt { if (!data_) usearch_raise_runtime_error("failed memory allocation"); - // Copy metadata + // Copy metadata. Only live entries are rehashed below, so the copy has no tombstones. buckets_ = other.buckets_; populated_slots_ = other.populated_slots_; + deleted_slots_ = 0; capacity_slots_ = other.capacity_slots_; // Initialize new buckets to empty std::memset(data_, 0, buckets_ * bytes_per_bucket()); - - // Copy elements and bucket headers - for (std::size_t i = 0; i < capacity_slots_; ++i) { - slot_ref_t old_slot = other.slot_ref(i); - if ((old_slot.header.populated & old_slot.mask) && !(old_slot.header.deleted & old_slot.mask)) { - slot_ref_t new_slot = slot_ref(i); - populate_slot(new_slot, old_slot.element); - } - } + rehash_into(other.data_, other.capacity_slots_, data_, capacity_slots_); } flat_hash_multi_set_gt& operator=(flat_hash_multi_set_gt const& other) { @@ -3848,22 +3871,15 @@ class flat_hash_multi_set_gt { if (!data_) usearch_raise_runtime_error("failed memory allocation"); - // Copy metadata + // Copy metadata. Only live entries are rehashed below, so the copy has no tombstones. buckets_ = other.buckets_; populated_slots_ = other.populated_slots_; + deleted_slots_ = 0; capacity_slots_ = other.capacity_slots_; // Initialize new buckets to empty std::memset(data_, 0, buckets_ * bytes_per_bucket()); - - // Copy elements and bucket headers - for (std::size_t i = 0; i < capacity_slots_; ++i) { - slot_ref_t old_slot = other.slot_ref(i); - if ((old_slot.header.populated & old_slot.mask) && !(old_slot.header.deleted & old_slot.mask)) { - slot_ref_t new_slot = slot_ref(i); - populate_slot(new_slot, old_slot.element); - } - } + rehash_into(other.data_, other.capacity_slots_, data_, capacity_slots_); return *this; } @@ -3880,6 +3896,7 @@ class flat_hash_multi_set_gt { if (data_) std::memset(data_, 0, buckets_ * bytes_per_bucket()); populated_slots_ = 0; + deleted_slots_ = 0; } void reset() noexcept { @@ -3889,11 +3906,19 @@ class flat_hash_multi_set_gt { data_ = nullptr; buckets_ = 0; populated_slots_ = 0; + deleted_slots_ = 0; capacity_slots_ = 0; } + /** + * @brief Grows the table to fit @p capacity live entries, reclaiming tombstones. + * + * Tombstones are reclaimed even when no growth is requested. They only ever stop + * being created once reclaimed, and a table with no empty slot left cannot + * terminate a probe, silently stranding live entries. + */ bool try_reserve(std::size_t capacity) noexcept { - if (capacity <= this->capacity()) + if (capacity <= this->capacity() && deleted_slots_ == 0) return true; // Calculate new sizes @@ -3913,7 +3938,15 @@ class flat_hash_multi_set_gt { checked_size_result_t new_slots = checked_mul(new_buckets_checked.value, slots_per_bucket()); if (!new_slots) return false; - checked_size_result_t new_bytes = checked_mul(new_buckets_checked.value, bytes_per_bucket()); + + // Never shrink: reclaiming tombstones alone needs no more than the current capacity + std::size_t target_buckets = new_buckets_checked.value; + std::size_t target_slots = new_slots.value; + if (target_slots < capacity_slots_) { + target_buckets = buckets_; + target_slots = capacity_slots_; + } + checked_size_result_t new_bytes = checked_mul(target_buckets, bytes_per_bucket()); if (!new_bytes) return false; @@ -3924,36 +3957,15 @@ class flat_hash_multi_set_gt { // Initialize new buckets to empty std::memset(new_data, 0, new_bytes.value); - - // Rehash and copy existing elements to new_data - hash_t hasher; - for (std::size_t i = 0; i < capacity_slots_; ++i) { - slot_ref_t old_slot = slot_ref(i); - if ((~old_slot.header.populated & old_slot.mask) | (old_slot.header.deleted & old_slot.mask)) - continue; - - // Rehash - std::size_t hash_value = hasher(old_slot.element); - std::size_t new_slot_index = hash_value & (new_slots.value - 1); - - // Linear probing to find an empty slot in new_data - while (true) { - slot_ref_t new_slot = slot_ref(new_data, new_slot_index); - if (!(new_slot.header.populated & new_slot.mask) || (new_slot.header.deleted & new_slot.mask)) { - populate_slot(new_slot, std::move(old_slot.element)); - new_slot.header.populated |= new_slot.mask; - break; - } - new_slot_index = (new_slot_index + 1) & (new_slots.value - 1); - } - } + rehash_into(data_, capacity_slots_, new_data, target_slots); // Deallocate old data and update pointers and sizes if (data_) allocator_t{}.deallocate(data_, buckets_ * bytes_per_bucket()); data_ = new_data; - buckets_ = new_buckets_checked.value; - capacity_slots_ = new_slots.value; + buckets_ = target_buckets; + capacity_slots_ = target_slots; + deleted_slots_ = 0; return true; } @@ -4082,6 +4094,7 @@ class flat_hash_multi_set_gt { // Found a match, mark as deleted slot.header.deleted |= slot.mask; --populated_slots_; + ++deleted_slots_; popped_value = slot.element; return true; // Successfully removed } @@ -4117,6 +4130,7 @@ class flat_hash_multi_set_gt { // Found a match, mark as deleted slot.header.deleted |= slot.mask; --populated_slots_; + ++deleted_slots_; ++count; // Increment count of elements removed } } else { @@ -4247,8 +4261,10 @@ class flat_hash_multi_set_gt { } bool try_emplace(element_t const& element) noexcept { - // Check if we need to resize - if (populated_slots_ * 3u >= capacity_slots_ * 2u) + // Both live entries and tombstones consume slots a probe must walk past, so the + // load factor counts them together. Under churn the live count alone stays flat + // and would never trigger the rehash that reclaims the tombstones. + if ((populated_slots_ + deleted_slots_) * 3u >= capacity_slots_ * 2u) if (!try_reserve(populated_slots_ + 1)) return false; @@ -4260,7 +4276,9 @@ class flat_hash_multi_set_gt { while (true) { slot_ref_t slot = slot_ref(slot_index); if ((~slot.header.populated & slot.mask) | (slot.header.deleted & slot.mask)) { - // Found an empty or deleted slot + // Found an empty or deleted slot; reusing a tombstone reclaims it. + // Read the tombstone bit before `populate_slot` clears it. + deleted_slots_ -= (slot.header.deleted & slot.mask) != 0; populate_slot(slot, element); ++populated_slots_; return true; From 3713af92af11a24bc772a72c533cbdb9569d6001 Mon Sep 17 00:00:00 2001 From: tang donghai Date: Fri, 10 Jul 2026 22:56:35 +0800 Subject: [PATCH 6/7] Add: Expose `stats()` in Rust SDK (#768) Closes #702 Co-Authored-By: Tang Donghai <72755185+tang-hi@users.noreply.github.com> --- rust/lib.cpp | 22 ++++++++++++++++ rust/lib.hpp | 4 +++ rust/lib.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/rust/lib.cpp b/rust/lib.cpp index 2fde17df..5a1ea541 100644 --- a/rust/lib.cpp +++ b/rust/lib.cpp @@ -272,6 +272,28 @@ MemoryStats NativeIndex::memory_stats() const { return result; } +static IndexStats to_index_stats(index_dense_t::stats_t const& stats) { + IndexStats result; + result.nodes = stats.nodes; + result.edges = stats.edges; + result.max_edges = stats.max_edges; + result.allocated_bytes = stats.allocated_bytes; + return result; +} + +IndexStats NativeIndex::stats() const { return to_index_stats(index_->stats()); } + +IndexStats NativeIndex::stats_for_level(size_t level) const { return to_index_stats(index_->stats(level)); } + +IndexStats NativeIndex::stats_per_level(rust::Slice stats_per_level, size_t max_level) const { + std::vector per_level(max_level + 1); + index_dense_t::stats_t aggregate = index_->stats(per_level.data(), max_level); + size_t exported = std::min(stats_per_level.size(), per_level.size()); + for (size_t i = 0; i != exported; ++i) + stats_per_level[i] = to_index_stats(per_level[i]); + return to_index_stats(aggregate); +} + char const* NativeIndex::hardware_acceleration() const { return index_->metric().isa_name(); } void NativeIndex::save_to_buffer(rust::Slice buffer) const { diff --git a/rust/lib.hpp b/rust/lib.hpp index 3f6ed211..bcadad2f 100644 --- a/rust/lib.hpp +++ b/rust/lib.hpp @@ -6,6 +6,7 @@ struct Matches; struct IndexOptions; struct IndexMetadata; struct MemoryStats; +struct IndexStats; enum class MetricKind; enum class ScalarKind; @@ -122,6 +123,9 @@ class NativeIndex { void reset() const; size_t memory_usage() const; MemoryStats memory_stats() const; + IndexStats stats() const; + IndexStats stats_for_level(size_t level) const; + IndexStats stats_per_level(rust::Slice stats_per_level, size_t max_level) const; char const* hardware_acceleration() const; void save_to_buffer(rust::Slice buffer) const; diff --git a/rust/lib.rs b/rust/lib.rs index 5a44bba2..4bac26fc 100644 --- a/rust/lib.rs +++ b/rust/lib.rs @@ -349,6 +349,20 @@ pub mod ffi { vectors_reserved: usize, } + /// Graph statistics aggregated across all levels of the HNSW index: + /// node and edge counts together with the memory used by the graph structure. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] + struct IndexStats { + /// Number of nodes (members) present in the graph. + nodes: usize, + /// Total number of edges (neighbor links) across all levels. + edges: usize, + /// Theoretical maximum number of edges given the connectivity, i.e. the edge capacity. + max_edges: usize, + /// Memory allocated for the graph structure (node tapes), in bytes. + allocated_bytes: usize, + } + /// The index options used to configure the dense index during creation. /// It contains the number of dimensions, the metric kind, the scalar kind, /// the connectivity, the expansion values, and the multi-flag. @@ -544,6 +558,9 @@ pub mod ffi { pub fn compact(self: &NativeIndex) -> Result<()>; pub fn memory_usage(self: &NativeIndex) -> usize; pub fn memory_stats(self: &NativeIndex) -> MemoryStats; + pub fn stats(self: &NativeIndex) -> IndexStats; + pub fn stats_for_level(self: &NativeIndex, level: usize) -> IndexStats; + pub fn stats_per_level(self: &NativeIndex, stats_per_level: &mut [IndexStats], max_level: usize) -> IndexStats; pub fn hardware_acceleration(self: &NativeIndex) -> *const c_char; pub fn save_to_buffer(self: &NativeIndex, buffer: &mut [u8]) -> Result<()>; @@ -553,7 +570,7 @@ pub mod ffi { } // Re-export the FFI structs and enums at the crate root for easy access -pub use ffi::{IndexMetadata, IndexOptions, MemoryStats, MetricKind, ScalarKind}; +pub use ffi::{IndexMetadata, IndexOptions, MemoryStats, IndexStats, MetricKind, ScalarKind}; /// Represents custom metric functions for calculating distances between vectors in various formats. /// @@ -1743,6 +1760,27 @@ impl Index { self.inner.memory_stats() } + /// Returns graph statistics aggregated across all levels: node and edge + /// counts together with the memory used by the graph structure. + pub fn stats(self: &Index) -> ffi::IndexStats { + self.inner.stats() + } + + /// Returns graph statistics for the nodes present at the given zero-based + /// `level`, where `0` is the base level. + pub fn stats_for_level(self: &Index, level: usize) -> ffi::IndexStats { + self.inner.stats_for_level(level) + } + + /// Returns per-level graph statistics for levels `0..=max_level`, where `0` + /// is the base level. The returned vector has `max_level + 1` entries, one + /// per level. + pub fn stats_per_level(self: &Index, max_level: usize) -> Vec { + let mut per_level = vec![ffi::IndexStats::default(); max_level + 1]; + self.inner.stats_per_level(&mut per_level, max_level); + per_level + } + /// Saves the index to a specified file. /// /// # Arguments @@ -2162,6 +2200,38 @@ mod tests { assert_eq!(index.size(), 0); } + #[test] + fn stats_variants() { + let options = IndexOptions { + dimensions: 4, + ..Default::default() + }; + let index = Index::new(&options).unwrap(); + index.reserve(10).unwrap(); + index.add(1, &[0.1, 0.2, 0.3, 0.4]).unwrap(); + index.add(2, &[0.2, 0.1, 0.4, 0.3]).unwrap(); + index.add(3, &[0.3, 0.4, 0.1, 0.2]).unwrap(); + + // Aggregate statistics across all levels. + let all = index.stats(); + assert_eq!(all.nodes, 3, "three members were added"); + assert!(all.edges > 0, "connected members should have edges"); + assert!(all.edges <= all.max_edges, "edges never exceed the capacity"); + assert!(all.allocated_bytes > 0, "graph should occupy memory"); + + // Every member always lives on the base level (0). + let base = index.stats_for_level(0); + assert_eq!(base.nodes, 3, "every member lives on the base level"); + + // Per-level breakdown for levels 0..=2 has `max_level + 1` entries. + let per_level = index.stats_per_level(2); + assert_eq!(per_level.len(), 3, "max_level + 1 entries"); + assert_eq!(per_level[0].nodes, 3, "base level holds all members"); + // Higher levels are subsets, so node counts are non-increasing. + assert!(per_level[0].nodes >= per_level[1].nodes); + assert!(per_level[1].nodes >= per_level[2].nodes); + } + #[test] fn integration() { let mut options = IndexOptions { From 017fad3c5f74311fd778349c56869ee87eb21f90 Mon Sep 17 00:00:00 2001 From: Ash Vardanian <1983160+ashvardanian@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:36:43 +0100 Subject: [PATCH 7/7] Make: Modernize CI toolchains, pin cibuildwheel The Python wheel jobs broke because `pip install cibuildwheel` is unpinned and drifted onto 4.x, which removed the `cpython-freethreading` enable group (now a hard parse error) and dropped the experimental CPython 3.13t free-threaded builds entirely. Pin `cibuildwheel~=4.1` so the major version no longer moves under us, drop the now-unbuildable `313t` matrix entries, and keep `314t` (3.14+ free-threading builds with no flag in 4.x). Verified build identifier selection against pyproject.toml with cibuildwheel 4.1. cibuildwheel 4.x also newly defaults the Windows `repair-wheel-command` to delvewheel, which bundles the MSVC runtime and fails on win_arm64 (no ARM64 msvcp140.dll to vendor on the runner). Earlier versions ran no Windows repair, so set it to empty to preserve that behavior. Refresh the rest of the CI toolchains to current best-practice versions, prioritising anything at or near end-of-life: - Node 20 -> 24: Node 20 reached EOL in April 2026; 24 (Krypton) is the active LTS and already used by the publish job. package.json `engines` floor raised 20 -> 22 (oldest still-maintained LTS). - .NET 8 -> 10: .NET 10 is the current LTS (Nov 2025). Test project retargeted net8.0 -> net10.0 and its stale test deps bumped (Test.Sdk 17.3 -> 18.7, xunit 2.4 -> 2.9, runner 2.4 -> 3.1, coverlet 3.1 -> 10.0); C# LangVersion 10 -> 13. Verified: dotnet build + 41/41 tests pass on net10. - Go 1.22 -> 1.25: 1.22 is past the two-release support window. Verified build + module graph on the go 1.26 toolchain. - Android NDK r26 -> r27c (27.2.12479018): r27 is the current LTS line. - Emscripten 3.1.47 -> 6.0.2: the 3.1.x line is long superseded. Verified by running the release WASM build locally; produces valid wasm objects. - Rust edition 2021 -> 2024: stable since Rust 1.85. Verified with a clean cargo check. - Java JUnit 4.13.2 -> JUnit 5 (Jupiter 5.12.2) with useJUnitPlatform(); Spotless 6.25 -> 8.8 (indentWithSpaces was removed, renamed to leadingTabsToSpaces). Assertions reordered to JUnit 5's message-last signature. Verified: gradle spotlessCheck + full test suite pass. - MinGW: unpin the stale 12.2.0 and move to setup-mingw@v3, letting the action install the runner's current MinGW-w64 default. The existing DLL architecture check still guards the output. --- .github/workflows/prerelease.yml | 23 +++--- .github/workflows/release.yml | 19 +++-- Cargo.toml | 2 +- build.gradle | 8 ++- csharp/Directory.Build.props | 2 +- .../Cloud.Unum.USearch.Tests.csproj | 10 +-- golang/go.mod | 2 +- java/test/IndexTest.java | 72 +++++++++---------- package.json | 2 +- pyproject.toml | 11 ++- 10 files changed, 77 insertions(+), 74 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index b61f37ae..f67d3a97 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -11,11 +11,11 @@ env: PYTHONUTF8: 1 PYTHONFAULTHANDLER: 1 PYTHON_VERSION: 3.11 - DOTNET_VERSION: 8.0.x - NODE_VERSION: 20 + DOTNET_VERSION: 10.0.x + NODE_VERSION: 24 JAVA_VERSION: 21 - GO_VERSION: "^1.22.0" - ANDROID_NDK_VERSION: 26.3.11579264 + GO_VERSION: "^1.25.0" + ANDROID_NDK_VERSION: 27.2.12479018 ANDROID_SDK_VERSION: 21 # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages @@ -408,7 +408,7 @@ jobs: needs: [test_ubuntu_gcc, test_ubuntu_clang] strategy: matrix: - python-version: ["310", "311", "312", "313", "313t", "314", "314t"] + python-version: ["310", "311", "312", "313", "314", "314t"] steps: - name: Checkout uses: actions/checkout@v6 @@ -424,7 +424,7 @@ jobs: max_attempts: 3 retry_wait_seconds: 10 timeout_minutes: 180 - command: python -m pip install cibuildwheel + command: python -m pip install "cibuildwheel~=4.1" - name: Build wheels uses: nick-fields/retry@v4 with: @@ -434,7 +434,6 @@ jobs: command: cibuildwheel --output-dir wheelhouse env: CIBW_BUILD: cp${{ matrix.python-version }}-* - CIBW_ENABLE: cpython-freethreading CIBW_PLATFORM: linux build_wheels_macos: @@ -443,7 +442,7 @@ jobs: needs: [test_macos] strategy: matrix: - python-version: ["310", "311", "312", "313", "313t", "314", "314t"] + python-version: ["310", "311", "312", "313", "314", "314t"] steps: - name: Checkout uses: actions/checkout@v6 @@ -457,7 +456,7 @@ jobs: max_attempts: 3 retry_wait_seconds: 10 timeout_minutes: 180 - command: python -m pip install cibuildwheel + command: python -m pip install "cibuildwheel~=4.1" - name: Build wheels uses: nick-fields/retry@v4 with: @@ -467,7 +466,6 @@ jobs: command: cibuildwheel --output-dir wheelhouse env: CIBW_BUILD: cp${{ matrix.python-version }}-* - CIBW_ENABLE: cpython-freethreading CIBW_PLATFORM: macos build_wheels_windows: @@ -476,7 +474,7 @@ jobs: needs: [test_windows_x86] strategy: matrix: - python-version: ["310", "311", "312", "313", "313t", "314", "314t"] + python-version: ["310", "311", "312", "313", "314", "314t"] steps: - name: Checkout uses: actions/checkout@v6 @@ -490,7 +488,7 @@ jobs: max_attempts: 3 retry_wait_seconds: 10 timeout_minutes: 180 - command: python -m pip install cibuildwheel + command: python -m pip install "cibuildwheel~=4.1" - name: Build wheels uses: nick-fields/retry@v4 with: @@ -500,7 +498,6 @@ jobs: command: cibuildwheel --output-dir wheelhouse env: CIBW_BUILD: cp${{ matrix.python-version }}-* - CIBW_ENABLE: cpython-freethreading CIBW_PLATFORM: windows test_ubuntu_cross_compilation: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 854bc9b8..a51fd82c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,14 +8,13 @@ env: GH_TOKEN: ${{ secrets.SEMANTIC_RELEASE_TOKEN }} PYTHONUTF8: 1 PYTHON_VERSION: 3.11 - DOTNET_VERSION: 8.0.x - NODE_VERSION: 20 + DOTNET_VERSION: 10.0.x + NODE_VERSION: 24 JAVA_VERSION: 21 - GO_VERSION: "^1.22.0" - ANDROID_NDK_VERSION: 26.3.11579264 + GO_VERSION: "^1.25.0" + ANDROID_NDK_VERSION: 27.2.12479018 ANDROID_SDK_VERSION: 21 - EMSCRIPTEN_VERSION: 3.1.47 - MINGW_VERSION: 12.2.0 + EMSCRIPTEN_VERSION: 6.0.2 # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: @@ -195,10 +194,9 @@ jobs: - name: Setup MinGW if: matrix.arch != 'arm64' - uses: egor-tensin/setup-mingw@v2 + uses: egor-tensin/setup-mingw@v3 with: platform: ${{ matrix.arch }} - version: ${{ env.MINGW_VERSION }} - name: Setup MSVC for ARM64 if: matrix.arch == 'arm64' @@ -750,7 +748,7 @@ jobs: strategy: matrix: os: [ubuntu-24.04, macos-14, windows-2022] - python-version: ["310", "311", "312", "313", "313t", "314", "314t"] + python-version: ["310", "311", "312", "313", "314", "314t"] steps: - name: Check out refreshed version uses: actions/checkout@v6 @@ -767,12 +765,11 @@ jobs: if: matrix.os == 'ubuntu-24.04' # We only need QEMU for Linux builds uses: docker/setup-qemu-action@v3 - name: Install cibuildwheel - run: python -m pip install cibuildwheel + run: python -m pip install "cibuildwheel~=4.1" - name: Build wheels run: cibuildwheel --output-dir wheelhouse env: CIBW_BUILD: cp${{ matrix.python-version }}-* - CIBW_ENABLE: cpython-freethreading # No-GIL 3.13t builds CIBW_TEST_SKIP: "*-win_arm64" # Too complex to emulate Windows ARM - name: Upload wheels uses: actions/upload-artifact@v5 diff --git a/Cargo.toml b/Cargo.toml index b7e81b5d..48fb6272 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ authors = ["Ash Vardanian <1983160+ashvardanian@users.noreply.github.com>"] description = "Smaller & Faster Single-File Vector Search Engine from Unum" documentation = "https://unum-cloud.github.io/USearch" -edition = "2021" +edition = "2024" include = [ "/rust/**", "/include/**", diff --git a/build.gradle b/build.gradle index dd82e8ff..b1a13afe 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { id 'cpp' id 'maven-publish' id 'signing' - id 'com.diffplug.spotless' version '6.25.0' + id 'com.diffplug.spotless' version '8.8.0' } group = "cloud.unum" @@ -57,14 +57,15 @@ task javadocJar(type: Jar, dependsOn: javadoc) { } dependencies { - testImplementation('junit:junit:4.13.2') + testImplementation('org.junit.jupiter:junit-jupiter:5.12.2') + testRuntimeOnly('org.junit.platform:junit-platform-launcher') } spotless { format 'gradle', { target '*.gradle' trimTrailingWhitespace() - indentWithSpaces(4) + leadingTabsToSpaces(4) endWithNewline() } @@ -196,6 +197,7 @@ model { } test { + useJUnitPlatform() forkEvery = 1 dependsOn jar diff --git a/csharp/Directory.Build.props b/csharp/Directory.Build.props index 0e97d6e2..eea47819 100644 --- a/csharp/Directory.Build.props +++ b/csharp/Directory.Build.props @@ -2,7 +2,7 @@ - 10 + 13 diff --git a/csharp/src/Cloud.Unum.USearch.Tests/Cloud.Unum.USearch.Tests.csproj b/csharp/src/Cloud.Unum.USearch.Tests/Cloud.Unum.USearch.Tests.csproj index 446797af..8aeb053d 100644 --- a/csharp/src/Cloud.Unum.USearch.Tests/Cloud.Unum.USearch.Tests.csproj +++ b/csharp/src/Cloud.Unum.USearch.Tests/Cloud.Unum.USearch.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable false enable @@ -10,13 +10,13 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/golang/go.mod b/golang/go.mod index e936b5d5..9ae7fffa 100644 --- a/golang/go.mod +++ b/golang/go.mod @@ -1,3 +1,3 @@ module github.com/unum-cloud/usearch/golang -go 1.22 +go 1.25 diff --git a/java/test/IndexTest.java b/java/test/IndexTest.java index 7795b621..ad4a0192 100644 --- a/java/test/IndexTest.java +++ b/java/test/IndexTest.java @@ -1,9 +1,9 @@ -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import cloud.unum.usearch.Index; import java.io.File; @@ -13,8 +13,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; -import org.junit.AfterClass; -import org.junit.Test; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; public class IndexTest { @@ -42,7 +42,7 @@ public void test() { } } - @AfterClass + @AfterAll public static void tearDown() { System.out.println("Java Tests Passed!"); } @@ -151,12 +151,12 @@ public void testMemoryUsage() { try (Index index = new Index.Config().metric("cos").dimensions(256).build()) { // Test empty index long initialMemory = index.memoryUsage(); - assertTrue("Initial memory usage should be positive", initialMemory > 0); + assertTrue(initialMemory > 0, "Initial memory usage should be positive"); // Add some vectors index.reserve(1000); long afterReserve = index.memoryUsage(); - assertTrue("Memory should increase after reserve", afterReserve >= initialMemory); + assertTrue(afterReserve >= initialMemory, "Memory should increase after reserve"); // Add vectors float[] vector = new float[256]; @@ -168,12 +168,12 @@ public void testMemoryUsage() { } long afterAdding = index.memoryUsage(); - assertTrue("Memory should increase after adding vectors", afterAdding > afterReserve); + assertTrue(afterAdding > afterReserve, "Memory should increase after adding vectors"); // Memory should be reasonable (not too small, not too large) assertTrue( - "Memory usage should be reasonable", - afterAdding > 1000 && afterAdding < 1_000_000_000L); + afterAdding > 1000 && afterAdding < 1_000_000_000L, + "Memory usage should be reasonable"); } } @@ -183,17 +183,17 @@ public void testHardwareAccelerationAPIs() { = new Index.Config().metric("cos").quantization("f32").dimensions(10).build()) { // Test hardware acceleration API String hardwareAcceleration = index.hardwareAcceleration(); - assertNotEquals("Hardware acceleration should not be null", null, hardwareAcceleration); + assertNotEquals(null, hardwareAcceleration, "Hardware acceleration should not be null"); assertTrue( - "Hardware acceleration should be non-empty", !hardwareAcceleration.isEmpty()); + !hardwareAcceleration.isEmpty(), "Hardware acceleration should be non-empty"); // Test metric kind API String metricKind = index.getMetricKind(); - assertEquals("Metric kind should be cos", "cos", metricKind); + assertEquals("cos", metricKind, "Metric kind should be cos"); // Test scalar kind API String scalarKind = index.getScalarKind(); - assertEquals("Scalar kind should be f32", "f32", scalarKind); + assertEquals("f32", scalarKind, "Scalar kind should be f32"); System.out.println("Hardware acceleration: " + hardwareAcceleration); System.out.println("Metric kind: " + metricKind); @@ -240,7 +240,7 @@ public void testMiniFloatQuantizations() { index.add(42, vec); long[] keys = index.search(vec, 1); - assertEquals("Self-match failed for " + quantization, 42L, keys[0]); + assertEquals(42L, keys[0], "Self-match failed for " + quantization); } } } @@ -583,9 +583,9 @@ public void testByteBufferPerformanceComparison() { long[] arrayResults = index.search(queryVector, 10); long[] bufferResults = bufferIndex.search(queryBuffer.asFloatBuffer(), 10); assertEquals( - "Search results should be equivalent", arrayResults.length, - bufferResults.length); + bufferResults.length, + "Search results should be equivalent"); } } } @@ -628,16 +628,16 @@ public void testSearchIntoZeroAllocation() { // Test searchInto - should find vector 3 first int found = index.searchInto(queryFloat, resultsLong, 5); - assertTrue("Should find at least 1 result", found >= 1); - assertTrue("Should find at most 5 results", found <= 5); + assertTrue(found >= 1, "Should find at least 1 result"); + assertTrue(found <= 5, "Should find at most 5 results"); // Verify buffer position was advanced assertEquals( - "Results buffer position should be advanced", found, resultsLong.position()); + found, resultsLong.position(), "Results buffer position should be advanced"); // First result should be key 3 (exact match) resultsLong.rewind(); - assertEquals("First result should be exact match", 3L, resultsLong.get(0)); + assertEquals(3L, resultsLong.get(0), "First result should be exact match"); } } @@ -673,8 +673,8 @@ public void testSearchIntoDoubleBuffer() { java.nio.LongBuffer resultsLong = resultsBuffer.asLongBuffer(); int found = index.searchInto(doubleBuffer, resultsLong, 3); - assertTrue("Should find results", found > 0); - assertEquals("First result should be key 102", 102L, resultsLong.get(0)); + assertTrue(found > 0, "Should find results"); + assertEquals(102L, resultsLong.get(0), "First result should be key 102"); } } @@ -708,8 +708,8 @@ public void testSearchIntoByteBuffer() { java.nio.LongBuffer resultsLong = resultsBuffer.asLongBuffer(); int found = index.searchInto(vectorBuffer, resultsLong, 2); - assertTrue("Should find results", found > 0); - assertEquals("First result should be key 201", 201L, resultsLong.get(0)); + assertTrue(found > 0, "Should find results"); + assertEquals(201L, resultsLong.get(0), "First result should be key 201"); } } @@ -717,13 +717,13 @@ public void testSearchIntoByteBuffer() { public void testPlatformCapabilities() { // Test runtime hardware capabilities String[] available = Index.hardwareAccelerationAvailable(); - assertNotEquals("Available capabilities should not be null", null, available); - assertTrue("Platform should have at least serial capability", available.length > 0); + assertNotEquals(null, available, "Available capabilities should not be null"); + assertTrue(available.length > 0, "Platform should have at least serial capability"); // Test compile-time capabilities String[] compiled = Index.hardwareAccelerationCompiled(); - assertNotEquals("Compiled capabilities should not be null", null, compiled); - assertTrue("Should have at least serial compiled", compiled.length > 0); + assertNotEquals(null, compiled, "Compiled capabilities should not be null"); + assertTrue(compiled.length > 0, "Should have at least serial compiled"); // Should always include serial as baseline in both boolean hasAvailableSerial = false; @@ -743,13 +743,13 @@ public void testPlatformCapabilities() { } } - assertTrue("Platform should always support serial capability", hasAvailableSerial); - assertTrue("Serial should always be compiled", hasCompiledSerial); + assertTrue(hasAvailableSerial, "Platform should always support serial capability"); + assertTrue(hasCompiledSerial, "Serial should always be compiled"); // Test library version String version = Index.version(); - assertNotEquals("Library version should not be null", null, version); - assertTrue("Library version should be non-empty", !version.isEmpty()); + assertNotEquals(null, version, "Library version should not be null"); + assertTrue(!version.isEmpty(), "Library version should be non-empty"); // Test dynamic dispatch detection boolean usesDynamicDispatch = Index.usesDynamicDispatch(); diff --git a/package.json b/package.json index f8fd1b72..e11a1073 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "email": "info@unum.cloud" }, "engines": { - "node": ">=20" + "node": ">=22" }, "files": [ "binding.gyp", diff --git a/pyproject.toml b/pyproject.toml index 7ef6a497..0301ed22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,10 @@ # - `macos` wheels for x86_64, arm64, and universal2; # - `windows` wheels for AMD64, and ARM64. But not x86. # - `manylinux` and `musllinux` wheels for Linux on x86_64, aarch64. But not i686, ppc64le, s390x; -# * for CPython versions from 3.10 to 3.14 (including free-threaded 3.13t and 3.14t). -# = meaning 7 platforms * 7 Python versions = 49 builds. +# * for CPython versions from 3.10 to 3.14 (including free-threaded 3.14t). +# = meaning 7 platforms * 6 Python versions = 42 builds. +# Note: cibuildwheel 4.x dropped the experimental 3.13t free-threaded builds and the +# `cpython-freethreading` enable flag; 3.14+ free-threading builds without any flag. [build-system] build-backend = "setuptools.build_meta" requires = [ @@ -81,6 +83,11 @@ repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest [tool.cibuildwheel.windows] archs = ["AMD64", "ARM64"] +# cibuildwheel 4.x defaults `repair-wheel-command` to delvewheel on Windows, which +# bundles the MSVC runtime into the wheel. That fails on win_arm64 (no ARM64 +# `msvcp140.dll` on the runner to vendor). Prior versions ran no Windows repair, so +# disable it to keep that behavior; the extension links the runtime as before. +repair-wheel-command = "" before-build = [ "rd /s /q {project}\\CMakeCache.txt {project}\\build {project}\\build_debug {project}\\CMakeFiles.txt {project}\\_deps {project}\\.pytest_cache || echo Done", "md build\\usearch",