usearch 2.25.3 → 2.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -545,11 +545,11 @@ index = Index(ndim=ndim, metric=CompiledMetric(
545
545
 
546
546
  ```bibtex
547
547
  @software{Vardanian_USearch,
548
- doi = {10.5281/zenodo.7949416},
548
+ doi = {10.5281/zenodo.7949415},
549
549
  author = {Vardanian, Ash},
550
550
  title = {{USearch by Unum Cloud}},
551
551
  url = {https://github.com/unum-cloud/USearch},
552
- version = {2.25.3},
552
+ version = {2.26.1},
553
553
  year = {2026},
554
554
  }
555
555
  ```
package/cpp/test.cpp CHANGED
@@ -1113,7 +1113,74 @@ template <typename key_at, typename slot_at> void test_strings() {
1113
1113
  }
1114
1114
 
1115
1115
  /**
1116
- * @brief Tests replacing and updating entries in index_dense_gt to ensure consistency after modifications.
1116
+ * @brief Churns a tightly-reserved index, so `slot_lookup_` accumulates tombstones.
1117
+ *
1118
+ * Removals leave tombstones that probes must walk past. If they are never reclaimed the
1119
+ * table runs out of empty slots, probes can no longer terminate, and live keys go missing:
1120
+ * `remove` reports nothing removed while `contains` still finds the key. Copying such a
1121
+ * table must rebuild the probe chains, not reproduce the slot layout.
1122
+ */
1123
+ template <typename key_at, typename slot_at> void test_slot_lookup_churn() {
1124
+ constexpr std::size_t live_count = 128;
1125
+ constexpr std::size_t churn_count = live_count * 8;
1126
+ constexpr std::size_t dimensions = 4;
1127
+
1128
+ using index_punned_t = index_dense_gt<key_at, slot_at>;
1129
+ metric_punned_t metric(dimensions, metric_kind_t::cos_k);
1130
+
1131
+ std::random_device seed_source;
1132
+ std::mt19937 generator(seed_source());
1133
+ std::uniform_real_distribution<float> distribution(0.0, 1.0);
1134
+ using vector_of_vectors_t = std::vector<std::vector<float>>;
1135
+
1136
+ vector_of_vectors_t vector_of_vectors(live_count + churn_count);
1137
+ for (auto& vector : vector_of_vectors) {
1138
+ vector.resize(dimensions);
1139
+ std::generate(vector.begin(), vector.end(), [&] { return distribution(generator); });
1140
+ }
1141
+
1142
+ index_punned_t index = index_punned_t::make(metric);
1143
+
1144
+ // Reserve tightly, so the churn below exhausts the empty slots
1145
+ index.reserve(live_count * 3);
1146
+
1147
+ std::vector<key_at> live_keys;
1148
+ std::size_t added = 0;
1149
+ for (; added < live_count; ++added) {
1150
+ index.add(static_cast<key_at>(added), vector_of_vectors[added].data());
1151
+ live_keys.push_back(static_cast<key_at>(added));
1152
+ }
1153
+
1154
+ // Every iteration frees one slot and immediately reuses it
1155
+ for (std::size_t idx = 0; idx < churn_count; ++idx, ++added) {
1156
+ key_at victim = live_keys[idx % live_count];
1157
+ expect(index.contains(victim));
1158
+ expect_eq(index.remove(victim).completed, 1);
1159
+ expect(!index.contains(victim));
1160
+ index.add(static_cast<key_at>(added), vector_of_vectors[added].data());
1161
+ live_keys[idx % live_count] = static_cast<key_at>(added);
1162
+ }
1163
+
1164
+ // Every live key stays reachable and the size never drifts
1165
+ expect_eq(index.size(), live_count);
1166
+ for (key_at key : live_keys)
1167
+ expect(index.contains(key));
1168
+
1169
+ // Copying a table that still holds tombstones must not strand live keys
1170
+ expect_eq(index.remove(live_keys.back()).completed, 1);
1171
+ live_keys.pop_back();
1172
+
1173
+ auto copy_result = index.copy();
1174
+ expect(copy_result);
1175
+ index_punned_t& copy = copy_result.index;
1176
+ expect_eq(copy.size(), live_keys.size());
1177
+ for (key_at key : live_keys)
1178
+ expect(copy.contains(key));
1179
+ }
1180
+
1181
+ /**
1182
+ * @brief Tests replacing and updating entries in `index_dense_gt`, to ensure consistency
1183
+ * after modifications.
1117
1184
  */
1118
1185
  template <typename key_at, typename slot_at> void test_replacing_update() {
1119
1186
 
@@ -1361,6 +1428,67 @@ void test_load_after_metric_make() {
1361
1428
  std::remove(path);
1362
1429
  }
1363
1430
 
1431
+ /**
1432
+ * @brief Feeds `view` a truncated index file.
1433
+ *
1434
+ * The matrix dimensions and the node count come from the file itself, so a short file
1435
+ * describes a matrix and a level array that reach past the mapping. Both must be rejected
1436
+ * rather than walked, and a caller-supplied offset past the end must fail the same way.
1437
+ */
1438
+ void test_view_of_truncated_file() {
1439
+ std::printf("Testing view of a truncated index file\n");
1440
+
1441
+ using index_t = index_dense_gt<std::int64_t, std::uint32_t>;
1442
+ std::size_t const dimensions = 64;
1443
+ std::size_t const collection = 2048;
1444
+ char const* path = "tmp_truncated.usearch";
1445
+
1446
+ metric_punned_t metric(dimensions, metric_kind_t::l2sq_k, scalar_kind<f32_t>());
1447
+ index_t::state_result_t built = index_t::make(metric);
1448
+ expect(built);
1449
+ expect(built.index.try_reserve(collection));
1450
+ std::vector<float> vector(dimensions);
1451
+ for (std::size_t i = 0; i != collection; ++i) {
1452
+ for (std::size_t d = 0; d != dimensions; ++d)
1453
+ vector[d] = static_cast<float>((i * 31 + d) % 997) / 997.f;
1454
+ expect(built.index.add(static_cast<std::int64_t>(i), vector.data()));
1455
+ }
1456
+ expect(built.index.save(path));
1457
+
1458
+ std::vector<char> prefix;
1459
+ {
1460
+ std::FILE* file = std::fopen(path, "rb");
1461
+ expect(file != nullptr);
1462
+ std::fseek(file, 0, SEEK_END);
1463
+ long const bytes = std::ftell(file);
1464
+ std::fseek(file, 0, SEEK_SET);
1465
+ prefix.resize(static_cast<std::size_t>(bytes) / 8);
1466
+ expect(std::fread(prefix.data(), 1, prefix.size(), file) == prefix.size());
1467
+ std::fclose(file);
1468
+ }
1469
+ {
1470
+ std::FILE* file = std::fopen(path, "wb");
1471
+ expect(file != nullptr);
1472
+ std::fwrite(prefix.data(), 1, prefix.size(), file);
1473
+ std::fclose(file);
1474
+ }
1475
+
1476
+ index_t::state_result_t viewed = index_t::make(metric);
1477
+ expect(viewed);
1478
+ serialization_result_t truncated = viewed.index.view(path);
1479
+ expect(!truncated);
1480
+ truncated.error.release();
1481
+
1482
+ // A caller-supplied offset past the end must fail the same way.
1483
+ index_t::state_result_t offset_viewed = index_t::make(metric);
1484
+ expect(offset_viewed);
1485
+ serialization_result_t beyond = offset_viewed.index.view(memory_mapped_file_t(path), prefix.size() * 4);
1486
+ expect(!beyond);
1487
+ beyond.error.release();
1488
+
1489
+ std::remove(path);
1490
+ }
1491
+
1364
1492
  int main(int, char**) {
1365
1493
  install_crash_handlers();
1366
1494
 
@@ -1450,8 +1578,13 @@ int main(int, char**) {
1450
1578
  test_sets<std::int64_t, slot32_t>(set_size, 20, 30);
1451
1579
  test_strings<std::int64_t, slot32_t>();
1452
1580
 
1581
+ std::printf("Testing key lookups under churn\n");
1582
+ test_slot_lookup_churn<std::int64_t, std::uint32_t>();
1583
+ test_slot_lookup_churn<std::int64_t, uint40_t>();
1584
+
1453
1585
  test_filtered_search();
1454
1586
  test_isolate();
1455
1587
  test_load_after_metric_make();
1588
+ test_view_of_truncated_file();
1456
1589
  return 0;
1457
1590
  }
@@ -8,8 +8,8 @@
8
8
  #define UNUM_USEARCH_HPP
9
9
 
10
10
  #define USEARCH_VERSION_MAJOR 2
11
- #define USEARCH_VERSION_MINOR 25
12
- #define USEARCH_VERSION_PATCH 3
11
+ #define USEARCH_VERSION_MINOR 26
12
+ #define USEARCH_VERSION_PATCH 1
13
13
 
14
14
  // Inferring C++ version
15
15
  // https://stackoverflow.com/a/61552074
@@ -1277,6 +1277,31 @@ template <typename element_at> struct hash_gt {
1277
1277
  std::size_t operator()(element_at const& element) const noexcept { return std::hash<element_at>{}(element); }
1278
1278
  };
1279
1279
 
1280
+ /**
1281
+ * @brief SplitMix64 finalizer, used to scatter integer keys before masking.
1282
+ *
1283
+ * On libstdc++ and libc++ `std::hash` is the identity for integers. Our open-addressing
1284
+ * tables mask that hash into a power-of-2 slot count and probe linearly until an @b empty
1285
+ * slot, so consecutive keys land in adjacent slots and merge into one contiguous run.
1286
+ * Both insertions and lookups then scan that run end-to-end, which is quadratic overall:
1287
+ * a dense ascending key range collapses insertion throughput by three orders of magnitude.
1288
+ * Mixing costs ~20ns per key and is dwarfed by the graph traversal in `add`.
1289
+ */
1290
+ template <> struct hash_gt<std::uint64_t> {
1291
+ std::size_t operator()(std::uint64_t const& element) const noexcept {
1292
+ std::uint64_t x = element;
1293
+ x = (x ^ (x >> 30u)) * 0xBF58476D1CE4E5B9ULL;
1294
+ x = (x ^ (x >> 27u)) * 0x94D049BB133111EBULL;
1295
+ return static_cast<std::size_t>(x ^ (x >> 31u));
1296
+ }
1297
+ };
1298
+
1299
+ template <> struct hash_gt<std::int64_t> {
1300
+ std::size_t operator()(std::int64_t const& element) const noexcept {
1301
+ return hash_gt<std::uint64_t>{}(static_cast<std::uint64_t>(element));
1302
+ }
1303
+ };
1304
+
1280
1305
  template <> struct hash_gt<uint40_t> {
1281
1306
  std::size_t operator()(uint40_t const& element) const noexcept { return std::hash<std::size_t>{}(element); }
1282
1307
  };
@@ -3945,6 +3970,10 @@ class index_gt {
3945
3970
  : checked_size_overflow();
3946
3971
  if (!first_offset)
3947
3972
  return result.failed("Index is too large");
3973
+ if (file.size() < first_offset.value) {
3974
+ reset();
3975
+ return result.failed("File is corrupted and can't fit the node levels");
3976
+ }
3948
3977
  offsets[0u] = first_offset.value;
3949
3978
  for (std::size_t i = 1; i < header_size.value; ++i) {
3950
3979
  checked_size_result_t next_offset = checked_add(offsets[i - 1], node_bytes_(levels[i - 1]));
@@ -1050,17 +1050,18 @@ class index_dense_gt {
1050
1050
  */
1051
1051
  bool try_reserve(index_limits_t limits) {
1052
1052
 
1053
+ // Reserving is monotonic: reducing thread limits must not shrink member-addressed storage.
1054
+ limits.members = (std::max)(limits.members, typed_->capacity());
1055
+ limits.members = (std::max)(limits.members, vectors_lookup_.size());
1056
+
1053
1057
  // The slot lookup system will generally prefer power-of-two sizes.
1054
1058
  if (config_.enable_key_lookups) {
1055
1059
  unique_lock_t lock(slot_lookup_mutex_);
1056
1060
  if (!slot_lookup_.try_reserve(limits.members))
1057
1061
  return false;
1058
- limits.members = slot_lookup_.capacity();
1059
1062
  }
1060
1063
 
1061
- // Once the `slot_lookup_` grows, let's use its capacity as the new
1062
- // target for the `vectors_lookup_` to synchronize allocations and
1063
- // expensive index re-organizations.
1064
+ // Hash-table load-factor slack does not need corresponding vector or graph slots.
1064
1065
  if (limits.members != vectors_lookup_.size()) {
1065
1066
  vectors_lookup_t new_vectors_lookup(limits.members);
1066
1067
  if (!new_vectors_lookup)
@@ -1358,6 +1359,11 @@ class index_dense_gt {
1358
1359
  if (!result)
1359
1360
  return result;
1360
1361
 
1362
+ // `offset` is caller-supplied, so every `file.size() - offset` below would
1363
+ // otherwise underflow into a huge span instead of failing.
1364
+ if (offset > file.size())
1365
+ return result.failed("File is corrupted and lacks matrix dimensions");
1366
+
1361
1367
  // Infer the new index size
1362
1368
  std::uint64_t matrix_rows = 0;
1363
1369
  std::uint64_t matrix_cols = 0;
@@ -1383,7 +1389,12 @@ class index_dense_gt {
1383
1389
  matrix_cols = dimensions[1];
1384
1390
  offset += sizeof(dimensions);
1385
1391
  }
1386
- vectors_buffer = {file.data() + offset, static_cast<std::size_t>(matrix_rows * matrix_cols)};
1392
+ // bound the vectors matrix span against the file (overflow-safe; offset <= file.size() holds here)
1393
+ checked_size_result_t vectors_bytes =
1394
+ checked_mul(static_cast<std::size_t>(matrix_rows), static_cast<std::size_t>(matrix_cols));
1395
+ if (!vectors_bytes || file.size() - offset < vectors_bytes.value)
1396
+ return result.failed("File is corrupted: vectors matrix exceeds file size");
1397
+ vectors_buffer = {file.data() + offset, vectors_bytes.value};
1387
1398
  offset += vectors_buffer.size();
1388
1399
  }
1389
1400
 
@@ -3746,6 +3746,8 @@ class flat_hash_multi_set_gt {
3746
3746
  char* data_ = nullptr;
3747
3747
  std::size_t buckets_ = 0;
3748
3748
  std::size_t populated_slots_ = 0;
3749
+ /// @brief Number of tombstones (slots marked deleted but not yet reclaimed)
3750
+ std::size_t deleted_slots_ = 0;
3749
3751
  /// @brief Number of slots
3750
3752
  std::size_t capacity_slots_ = 0;
3751
3753
 
@@ -3781,6 +3783,34 @@ class flat_hash_multi_set_gt {
3781
3783
  }
3782
3784
  }
3783
3785
 
3786
+ /**
3787
+ * @brief Copies every live entry of @p source into the freshly zeroed @p target.
3788
+ *
3789
+ * Tombstones are never carried over, so probe chains must be rebuilt from the hash
3790
+ * rather than reproduced slot-for-slot: a live entry displaced past a tombstone by
3791
+ * linear probing would otherwise become unreachable once that tombstone reads empty.
3792
+ * The target holds no tombstones, so an unpopulated slot is always a free slot.
3793
+ */
3794
+ void rehash_into(char* source, std::size_t source_slots, char* target, std::size_t target_slots) const noexcept {
3795
+ hash_t hasher;
3796
+ for (std::size_t i = 0; i != source_slots; ++i) {
3797
+ slot_ref_t source_slot = slot_ref(source, i);
3798
+ if (!(source_slot.header.populated & source_slot.mask) || (source_slot.header.deleted & source_slot.mask))
3799
+ continue;
3800
+
3801
+ std::size_t target_index = hasher(source_slot.element) & (target_slots - 1);
3802
+ while (true) {
3803
+ slot_ref_t target_slot = slot_ref(target, target_index);
3804
+ if (!(target_slot.header.populated & target_slot.mask)) {
3805
+ new (&target_slot.element) element_t(source_slot.element);
3806
+ target_slot.header.populated |= target_slot.mask;
3807
+ break;
3808
+ }
3809
+ target_index = (target_index + 1) & (target_slots - 1);
3810
+ }
3811
+ }
3812
+ }
3813
+
3784
3814
  public:
3785
3815
  std::size_t size() const noexcept { return populated_slots_; }
3786
3816
  std::size_t capacity() const noexcept { return capacity_slots_ * 2u / 3u; }
@@ -3805,22 +3835,15 @@ class flat_hash_multi_set_gt {
3805
3835
  if (!data_)
3806
3836
  usearch_raise_runtime_error("failed memory allocation");
3807
3837
 
3808
- // Copy metadata
3838
+ // Copy metadata. Only live entries are rehashed below, so the copy has no tombstones.
3809
3839
  buckets_ = other.buckets_;
3810
3840
  populated_slots_ = other.populated_slots_;
3841
+ deleted_slots_ = 0;
3811
3842
  capacity_slots_ = other.capacity_slots_;
3812
3843
 
3813
3844
  // Initialize new buckets to empty
3814
3845
  std::memset(data_, 0, buckets_ * bytes_per_bucket());
3815
-
3816
- // Copy elements and bucket headers
3817
- for (std::size_t i = 0; i < capacity_slots_; ++i) {
3818
- slot_ref_t old_slot = other.slot_ref(i);
3819
- if ((old_slot.header.populated & old_slot.mask) && !(old_slot.header.deleted & old_slot.mask)) {
3820
- slot_ref_t new_slot = slot_ref(i);
3821
- populate_slot(new_slot, old_slot.element);
3822
- }
3823
- }
3846
+ rehash_into(other.data_, other.capacity_slots_, data_, capacity_slots_);
3824
3847
  }
3825
3848
 
3826
3849
  flat_hash_multi_set_gt& operator=(flat_hash_multi_set_gt const& other) {
@@ -3848,22 +3871,15 @@ class flat_hash_multi_set_gt {
3848
3871
  if (!data_)
3849
3872
  usearch_raise_runtime_error("failed memory allocation");
3850
3873
 
3851
- // Copy metadata
3874
+ // Copy metadata. Only live entries are rehashed below, so the copy has no tombstones.
3852
3875
  buckets_ = other.buckets_;
3853
3876
  populated_slots_ = other.populated_slots_;
3877
+ deleted_slots_ = 0;
3854
3878
  capacity_slots_ = other.capacity_slots_;
3855
3879
 
3856
3880
  // Initialize new buckets to empty
3857
3881
  std::memset(data_, 0, buckets_ * bytes_per_bucket());
3858
-
3859
- // Copy elements and bucket headers
3860
- for (std::size_t i = 0; i < capacity_slots_; ++i) {
3861
- slot_ref_t old_slot = other.slot_ref(i);
3862
- if ((old_slot.header.populated & old_slot.mask) && !(old_slot.header.deleted & old_slot.mask)) {
3863
- slot_ref_t new_slot = slot_ref(i);
3864
- populate_slot(new_slot, old_slot.element);
3865
- }
3866
- }
3882
+ rehash_into(other.data_, other.capacity_slots_, data_, capacity_slots_);
3867
3883
 
3868
3884
  return *this;
3869
3885
  }
@@ -3880,6 +3896,7 @@ class flat_hash_multi_set_gt {
3880
3896
  if (data_)
3881
3897
  std::memset(data_, 0, buckets_ * bytes_per_bucket());
3882
3898
  populated_slots_ = 0;
3899
+ deleted_slots_ = 0;
3883
3900
  }
3884
3901
 
3885
3902
  void reset() noexcept {
@@ -3889,11 +3906,19 @@ class flat_hash_multi_set_gt {
3889
3906
  data_ = nullptr;
3890
3907
  buckets_ = 0;
3891
3908
  populated_slots_ = 0;
3909
+ deleted_slots_ = 0;
3892
3910
  capacity_slots_ = 0;
3893
3911
  }
3894
3912
 
3913
+ /**
3914
+ * @brief Grows the table to fit @p capacity live entries, reclaiming tombstones.
3915
+ *
3916
+ * Tombstones are reclaimed even when no growth is requested. They only ever stop
3917
+ * being created once reclaimed, and a table with no empty slot left cannot
3918
+ * terminate a probe, silently stranding live entries.
3919
+ */
3895
3920
  bool try_reserve(std::size_t capacity) noexcept {
3896
- if (capacity <= this->capacity())
3921
+ if (capacity <= this->capacity() && deleted_slots_ == 0)
3897
3922
  return true;
3898
3923
 
3899
3924
  // Calculate new sizes
@@ -3913,7 +3938,15 @@ class flat_hash_multi_set_gt {
3913
3938
  checked_size_result_t new_slots = checked_mul(new_buckets_checked.value, slots_per_bucket());
3914
3939
  if (!new_slots)
3915
3940
  return false;
3916
- checked_size_result_t new_bytes = checked_mul(new_buckets_checked.value, bytes_per_bucket());
3941
+
3942
+ // Never shrink: reclaiming tombstones alone needs no more than the current capacity
3943
+ std::size_t target_buckets = new_buckets_checked.value;
3944
+ std::size_t target_slots = new_slots.value;
3945
+ if (target_slots < capacity_slots_) {
3946
+ target_buckets = buckets_;
3947
+ target_slots = capacity_slots_;
3948
+ }
3949
+ checked_size_result_t new_bytes = checked_mul(target_buckets, bytes_per_bucket());
3917
3950
  if (!new_bytes)
3918
3951
  return false;
3919
3952
 
@@ -3924,36 +3957,15 @@ class flat_hash_multi_set_gt {
3924
3957
 
3925
3958
  // Initialize new buckets to empty
3926
3959
  std::memset(new_data, 0, new_bytes.value);
3927
-
3928
- // Rehash and copy existing elements to new_data
3929
- hash_t hasher;
3930
- for (std::size_t i = 0; i < capacity_slots_; ++i) {
3931
- slot_ref_t old_slot = slot_ref(i);
3932
- if ((~old_slot.header.populated & old_slot.mask) | (old_slot.header.deleted & old_slot.mask))
3933
- continue;
3934
-
3935
- // Rehash
3936
- std::size_t hash_value = hasher(old_slot.element);
3937
- std::size_t new_slot_index = hash_value & (new_slots.value - 1);
3938
-
3939
- // Linear probing to find an empty slot in new_data
3940
- while (true) {
3941
- slot_ref_t new_slot = slot_ref(new_data, new_slot_index);
3942
- if (!(new_slot.header.populated & new_slot.mask) || (new_slot.header.deleted & new_slot.mask)) {
3943
- populate_slot(new_slot, std::move(old_slot.element));
3944
- new_slot.header.populated |= new_slot.mask;
3945
- break;
3946
- }
3947
- new_slot_index = (new_slot_index + 1) & (new_slots.value - 1);
3948
- }
3949
- }
3960
+ rehash_into(data_, capacity_slots_, new_data, target_slots);
3950
3961
 
3951
3962
  // Deallocate old data and update pointers and sizes
3952
3963
  if (data_)
3953
3964
  allocator_t{}.deallocate(data_, buckets_ * bytes_per_bucket());
3954
3965
  data_ = new_data;
3955
- buckets_ = new_buckets_checked.value;
3956
- capacity_slots_ = new_slots.value;
3966
+ buckets_ = target_buckets;
3967
+ capacity_slots_ = target_slots;
3968
+ deleted_slots_ = 0;
3957
3969
 
3958
3970
  return true;
3959
3971
  }
@@ -4082,6 +4094,7 @@ class flat_hash_multi_set_gt {
4082
4094
  // Found a match, mark as deleted
4083
4095
  slot.header.deleted |= slot.mask;
4084
4096
  --populated_slots_;
4097
+ ++deleted_slots_;
4085
4098
  popped_value = slot.element;
4086
4099
  return true; // Successfully removed
4087
4100
  }
@@ -4117,6 +4130,7 @@ class flat_hash_multi_set_gt {
4117
4130
  // Found a match, mark as deleted
4118
4131
  slot.header.deleted |= slot.mask;
4119
4132
  --populated_slots_;
4133
+ ++deleted_slots_;
4120
4134
  ++count; // Increment count of elements removed
4121
4135
  }
4122
4136
  } else {
@@ -4247,8 +4261,10 @@ class flat_hash_multi_set_gt {
4247
4261
  }
4248
4262
 
4249
4263
  bool try_emplace(element_t const& element) noexcept {
4250
- // Check if we need to resize
4251
- if (populated_slots_ * 3u >= capacity_slots_ * 2u)
4264
+ // Both live entries and tombstones consume slots a probe must walk past, so the
4265
+ // load factor counts them together. Under churn the live count alone stays flat
4266
+ // and would never trigger the rehash that reclaims the tombstones.
4267
+ if ((populated_slots_ + deleted_slots_) * 3u >= capacity_slots_ * 2u)
4252
4268
  if (!try_reserve(populated_slots_ + 1))
4253
4269
  return false;
4254
4270
 
@@ -4260,7 +4276,9 @@ class flat_hash_multi_set_gt {
4260
4276
  while (true) {
4261
4277
  slot_ref_t slot = slot_ref(slot_index);
4262
4278
  if ((~slot.header.populated & slot.mask) | (slot.header.deleted & slot.mask)) {
4263
- // Found an empty or deleted slot
4279
+ // Found an empty or deleted slot; reusing a tombstone reclaims it.
4280
+ // Read the tombstone bit before `populate_slot` clears it.
4281
+ deleted_slots_ -= (slot.header.deleted & slot.mask) != 0;
4264
4282
  populate_slot(slot, element);
4265
4283
  ++populated_slots_;
4266
4284
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usearch",
3
- "version": "2.25.3",
3
+ "version": "2.26.1",
4
4
  "description": "Smaller & Faster Single-File Vector Search Engine from Unum",
5
5
  "author": "Ash Vardanian (https://ashvardanian.com/)",
6
6
  "license": "Apache 2.0",
@@ -14,7 +14,7 @@
14
14
  "email": "info@unum.cloud"
15
15
  },
16
16
  "engines": {
17
- "node": ">=20"
17
+ "node": ">=22"
18
18
  },
19
19
  "files": [
20
20
  "binding.gyp",
Binary file
Binary file
Binary file