diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index afcdf874..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 @@ -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: @@ -404,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 @@ -420,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: @@ -430,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: @@ -439,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 @@ -453,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: @@ -463,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: @@ -472,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 @@ -486,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: @@ -496,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 cde29d93..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 @@ -813,19 +810,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 +839,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 @@ -1136,6 +1132,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 +1143,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 +1189,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 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/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/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/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); } }; 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; 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", diff --git a/rust/lib.cpp b/rust/lib.cpp index 5ca243c5..5a1ea541 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(); } @@ -267,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 8f31f915..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; @@ -118,9 +119,13 @@ 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; + 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 95807483..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. @@ -541,8 +555,12 @@ 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 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<()>; @@ -552,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. /// @@ -1715,6 +1733,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() @@ -1732,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 @@ -2151,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 {