usearch 0.21.0 → 0.22.0

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.
@@ -41,6 +41,13 @@
41
41
  #define USEARCH_DEFINED_ARM
42
42
  #endif
43
43
 
44
+ // Inferring hardware bitness: 32 vs 64
45
+ #if __WORDSIZE == 64
46
+ #define USEARCH_64BIT_ENV
47
+ #else
48
+ #define USEARCH_32BIT_ENV
49
+ #endif
50
+
44
51
  #if !defined(USEARCH_USE_OPENMP)
45
52
  #define USEARCH_USE_OPENMP 0
46
53
  #endif
@@ -173,19 +180,21 @@ inline std::size_t ceil2(std::size_t v) noexcept {
173
180
  v |= v >> 4;
174
181
  v |= v >> 8;
175
182
  v |= v >> 16;
183
+ #ifdef USEARCH_64BIT_ENV
176
184
  v |= v >> 32;
185
+ #endif
177
186
  v++;
178
187
  return v;
179
188
  }
180
189
 
181
190
  template <typename at> void misaligned_store(void* ptr, at v) noexcept {
182
- static_assert(!std::is_reference<at>::value);
191
+ static_assert(!std::is_reference<at>::value, "Can't store a reference");
183
192
  std::memcpy(ptr, &v, sizeof(at));
184
193
  }
185
194
 
186
195
  /// @brief Simply dereferencing misaligned pointers can be dangerous.
187
196
  template <typename at> at misaligned_load(void* ptr) noexcept {
188
- static_assert(!std::is_reference<at>::value);
197
+ static_assert(!std::is_reference<at>::value, "Can't load a reference");
189
198
  at v;
190
199
  std::memcpy(&v, ptr, sizeof(at));
191
200
  return v;
@@ -984,12 +993,19 @@ class sorted_buffer_gt {
984
993
 
985
994
  /**
986
995
  * @brief Five-byte integer type to address node clouds with over 4B entries.
996
+ *
997
+ * @note Avoid usage in 32bit environment
987
998
  */
988
999
  class usearch_pack_m uint40_t {
989
1000
  unsigned char octets[5];
990
1001
 
1002
+ inline uint40_t& broadcast(unsigned char c) {
1003
+ std::memset(octets, c, 5);
1004
+ return *this;
1005
+ }
1006
+
991
1007
  public:
992
- inline uint40_t() noexcept { std::memset(octets, 0, 5); }
1008
+ inline uint40_t() noexcept { broadcast(0); }
993
1009
  inline uint40_t(std::uint32_t n) noexcept { std::memcpy(octets, (char*)&n, 4); }
994
1010
  inline uint40_t(std::uint64_t n) noexcept { std::memcpy(octets, (char*)&n, 5); }
995
1011
  #if defined(USEARCH_DEFINED_CLANG) && defined(USEARCH_DEFINED_APPLE)
@@ -1026,7 +1042,11 @@ class usearch_pack_m uint40_t {
1026
1042
 
1027
1043
  inline operator std::size_t() const noexcept {
1028
1044
  std::size_t result = 0;
1029
- std::memcpy((char*)&result, octets, 5);
1045
+ #ifdef USEARCH_64BIT_ENV
1046
+ std::memcpy((char*)&result + 3, octets, 5);
1047
+ #else
1048
+ std::memcpy((char*)&result, octets + 1, 4);
1049
+ #endif
1030
1050
  return result;
1031
1051
  }
1032
1052
 
@@ -1037,6 +1057,9 @@ class usearch_pack_m uint40_t {
1037
1057
  *this += 1u;
1038
1058
  return old;
1039
1059
  }
1060
+
1061
+ inline static uint40_t max() noexcept { return uint40_t{}.broadcast(0xFF); }
1062
+ inline static uint40_t min() noexcept { return uint40_t{}.broadcast(0); }
1040
1063
  };
1041
1064
 
1042
1065
  #if defined(USEARCH_DEFINED_WINDOWS)
@@ -1057,23 +1080,38 @@ class ring_gt {
1057
1080
  using value_type = element_t;
1058
1081
 
1059
1082
  private:
1060
- element_t* elements_;
1061
- std::size_t capacity_;
1062
- std::size_t head_;
1063
- std::size_t tail_;
1064
- bool empty_;
1065
- allocator_t allocator_;
1083
+ element_t* elements_{};
1084
+ std::size_t capacity_{};
1085
+ std::size_t head_{};
1086
+ std::size_t tail_{};
1087
+ bool empty_{true};
1088
+ allocator_t allocator_{};
1066
1089
 
1067
1090
  public:
1068
- explicit ring_gt(allocator_t const& alloc = allocator_t()) noexcept
1069
- : elements_(nullptr), capacity_(0), head_(0), tail_(0), empty_(true), allocator_(alloc) {}
1091
+ explicit ring_gt(allocator_t const& alloc = allocator_t()) noexcept : allocator_(alloc) {}
1070
1092
 
1071
1093
  ring_gt(ring_gt const&) = delete;
1072
1094
  ring_gt& operator=(ring_gt const&) = delete;
1073
1095
 
1096
+ ring_gt(ring_gt&& other) noexcept { swap(other); }
1097
+ ring_gt& operator=(ring_gt&& other) noexcept {
1098
+ swap(other);
1099
+ return *this;
1100
+ }
1101
+
1102
+ void swap(ring_gt& other) noexcept {
1103
+ std::swap(elements_, other.elements_);
1104
+ std::swap(capacity_, other.capacity_);
1105
+ std::swap(head_, other.head_);
1106
+ std::swap(tail_, other.tail_);
1107
+ std::swap(empty_, other.empty_);
1108
+ std::swap(allocator_, other.allocator_);
1109
+ }
1110
+
1074
1111
  ~ring_gt() noexcept { reset(); }
1075
1112
 
1076
1113
  bool empty() const noexcept { return empty_; }
1114
+ size_t capacity() const noexcept { return capacity_; }
1077
1115
  size_t size() const noexcept {
1078
1116
  if (empty_)
1079
1117
  return 0;
@@ -1083,6 +1121,12 @@ class ring_gt {
1083
1121
  return capacity_ - (tail_ - head_);
1084
1122
  }
1085
1123
 
1124
+ void clear() noexcept {
1125
+ head_ = 0;
1126
+ tail_ = 0;
1127
+ empty_ = true;
1128
+ }
1129
+
1086
1130
  void reset() noexcept {
1087
1131
  if (elements_)
1088
1132
  allocator_.deallocate(elements_, capacity_);
@@ -1093,13 +1137,24 @@ class ring_gt {
1093
1137
  empty_ = true;
1094
1138
  }
1095
1139
 
1096
- bool resize(std::size_t n) noexcept {
1140
+ bool reserve(std::size_t n) noexcept {
1141
+ if (n < size())
1142
+ return false; // prevent data loss
1143
+ n = (std::max<std::size_t>)(n, 64u);
1097
1144
  element_t* elements = allocator_.allocate(n);
1098
1145
  if (!elements)
1099
1146
  return false;
1147
+
1148
+ std::size_t i = 0;
1149
+ while (try_pop(elements[i]))
1150
+ i++;
1151
+
1100
1152
  reset();
1101
1153
  elements_ = elements;
1102
1154
  capacity_ = n;
1155
+ head_ = i;
1156
+ tail_ = 0;
1157
+ empty_ = (i == 0);
1103
1158
  return true;
1104
1159
  }
1105
1160
 
@@ -1126,6 +1181,8 @@ class ring_gt {
1126
1181
  empty_ = head_ == tail_;
1127
1182
  return true;
1128
1183
  }
1184
+
1185
+ element_t const& operator[](std::size_t i) const noexcept { return elements_[(tail_ + i) % capacity_]; }
1129
1186
  };
1130
1187
 
1131
1188
  /// @brief Number of neighbors per graph node.
@@ -1173,11 +1230,11 @@ struct index_config_t {
1173
1230
  };
1174
1231
 
1175
1232
  struct index_limits_t {
1176
- std::size_t elements = 0;
1233
+ std::size_t members = 0;
1177
1234
  std::size_t threads_add = std::thread::hardware_concurrency();
1178
1235
  std::size_t threads_search = std::thread::hardware_concurrency();
1179
1236
 
1180
- index_limits_t(std::size_t n, std::size_t t) noexcept : elements(n), threads_add(t), threads_search(t) {}
1237
+ index_limits_t(std::size_t n, std::size_t t) noexcept : members(n), threads_add(t), threads_search(t) {}
1181
1238
  index_limits_t(std::size_t n = 0) noexcept : index_limits_t(n, std::thread::hardware_concurrency()) {}
1182
1239
  std::size_t threads() const noexcept { return (std::max)(threads_add, threads_search); }
1183
1240
  std::size_t concurrency() const noexcept { return (std::min)(threads_add, threads_search); }
@@ -1457,7 +1514,7 @@ struct dummy_label_to_label_mapping_t {
1457
1514
  *
1458
1515
  */
1459
1516
  template <typename metric_at = ip_gt<float>, //
1460
- typename label_at = std::size_t, //
1517
+ typename label_at = std::int64_t, //
1461
1518
  typename id_at = std::uint32_t, //
1462
1519
  typename allocator_at = std::allocator<char>, //
1463
1520
  typename point_allocator_at = allocator_at> //
@@ -1474,16 +1531,18 @@ class index_gt {
1474
1531
  using vector_view_t = span_gt<scalar_t const>;
1475
1532
  using distance_t = return_type_gt<metric_t, vector_view_t, vector_view_t>;
1476
1533
 
1477
- struct member_ref_t {
1478
- misaligned_ref_gt<label_t> label;
1534
+ struct member_cref_t {
1535
+ label_t label;
1479
1536
  vector_view_t vector;
1480
1537
  id_t id;
1481
1538
  };
1482
1539
 
1483
- struct member_cref_t {
1484
- label_t label;
1540
+ struct member_ref_t {
1541
+ misaligned_ref_gt<label_t> label;
1485
1542
  vector_view_t vector;
1486
1543
  id_t id;
1544
+
1545
+ inline operator member_cref_t() const noexcept { return {label, vector, id}; }
1487
1546
  };
1488
1547
 
1489
1548
  template <typename ref_at, typename index_at> class member_iterator_gt {
@@ -1774,6 +1833,9 @@ class index_gt {
1774
1833
  member_iterator_t begin() noexcept { return {this, 0}; }
1775
1834
  member_iterator_t end() noexcept { return {this, size()}; }
1776
1835
 
1836
+ member_cref_t at(std::size_t i) const noexcept { return member_cref_t(node_with_id_(i)); }
1837
+ member_ref_t at(std::size_t i) noexcept { return member_ref_t(node_with_id_(i)); }
1838
+
1777
1839
  #pragma region Adjusting Configuration
1778
1840
 
1779
1841
  /**
@@ -1799,7 +1861,7 @@ class index_gt {
1799
1861
  clear();
1800
1862
 
1801
1863
  if (nodes_)
1802
- node_allocator_t{}.deallocate(exchange(nodes_, nullptr), limits_.elements);
1864
+ node_allocator_t{}.deallocate(exchange(nodes_, nullptr), limits_.members);
1803
1865
  if (contexts_) {
1804
1866
  for (std::size_t i = 0; i != limits_.threads(); ++i)
1805
1867
  contexts_[i].~context_t();
@@ -1842,13 +1904,16 @@ class index_gt {
1842
1904
  */
1843
1905
  bool reserve(index_limits_t limits) usearch_noexcept_m {
1844
1906
 
1845
- usearch_assert_m(limits.elements >= size_, "Can't drop existing values");
1907
+ if (limits.threads_add <= limits_.threads_add //
1908
+ && limits.threads_search <= limits_.threads_search //
1909
+ && limits.members <= limits_.members)
1910
+ return true;
1846
1911
 
1847
- if (!nodes_mutexes_.resize(limits.elements))
1912
+ if (!nodes_mutexes_.resize(limits.members))
1848
1913
  return false;
1849
1914
 
1850
1915
  node_allocator_t node_allocator;
1851
- node_t* new_nodes = node_allocator.allocate(limits.elements);
1916
+ node_t* new_nodes = node_allocator.allocate(limits.members);
1852
1917
  if (!new_nodes)
1853
1918
  return false;
1854
1919
 
@@ -1856,17 +1921,17 @@ class index_gt {
1856
1921
  context_allocator_t context_allocator;
1857
1922
  context_t* new_contexts = context_allocator.allocate(limits_threads);
1858
1923
  if (!new_contexts) {
1859
- node_allocator.deallocate(new_nodes, limits.elements);
1924
+ node_allocator.deallocate(new_nodes, limits.members);
1860
1925
  return false;
1861
1926
  }
1862
1927
  for (std::size_t i = 0; i != limits_threads; ++i) {
1863
1928
  context_t& context = new_contexts[i];
1864
1929
  new (&context) context_t();
1865
1930
  context.metric = metric_;
1866
- if (!context.visits.resize(limits.elements)) {
1931
+ if (!context.visits.resize(limits.members)) {
1867
1932
  for (std::size_t j = 0; j != i; ++j)
1868
1933
  context.visits.reset();
1869
- node_allocator.deallocate(new_nodes, limits.elements);
1934
+ node_allocator.deallocate(new_nodes, limits.members);
1870
1935
  context_allocator.deallocate(new_contexts, limits_threads);
1871
1936
  return false;
1872
1937
  }
@@ -1886,13 +1951,12 @@ class index_gt {
1886
1951
 
1887
1952
  // Move the nodes info, and deallocate previous buffers.
1888
1953
  if (nodes_)
1889
- std::memcpy(new_nodes, nodes_, sizeof(node_t) * size()),
1890
- node_allocator.deallocate(nodes_, limits_.elements);
1954
+ std::memcpy(new_nodes, nodes_, sizeof(node_t) * size()), node_allocator.deallocate(nodes_, limits_.members);
1891
1955
  if (contexts_)
1892
1956
  context_allocator.deallocate(contexts_, limits_.threads());
1893
1957
 
1894
1958
  limits_ = limits;
1895
- capacity_ = limits.elements;
1959
+ capacity_ = limits.members;
1896
1960
  nodes_ = new_nodes;
1897
1961
  contexts_ = new_contexts;
1898
1962
  return true;
@@ -2053,16 +2117,7 @@ class index_gt {
2053
2117
  result.measurements = context.measurements_count;
2054
2118
  result.cycles = context.iteration_cycles;
2055
2119
 
2056
- // Go down the level, tracking only the closest match
2057
- id_t closest_id = search_for_one_(entry_id_copy, vector, max_level_copy, target_level, context);
2058
-
2059
- // From `target_level` down perform proper extensive search
2060
- for (level_t level = (std::min)(target_level, max_level_copy); level >= 0; --level) {
2061
- // TODO: Handle out of memory conditions
2062
- search_to_insert_(closest_id, vector, level, config.expansion, context);
2063
- closest_id = connect_new_node_(new_id, level, context);
2064
- reconnect_neighbor_nodes_(new_id, level, context);
2065
- }
2120
+ connect_node_across_levels_(new_id, vector, entry_id_copy, max_level_copy, target_level, config, context);
2066
2121
 
2067
2122
  // Normalize stats
2068
2123
  result.measurements = context.measurements_count - result.measurements;
@@ -2076,6 +2131,51 @@ class index_gt {
2076
2131
  return result;
2077
2132
  }
2078
2133
 
2134
+ /**
2135
+ * @brief Update an existing entry, replacing a vector and a label. Thread-safe.
2136
+ *
2137
+ * @param[in] old_id Existing internal identifier for a node to be replaced.
2138
+ * @param[in] label External identifier/name/descriptor for the vector.
2139
+ * @param[in] vector Contiguous range of scalars forming a vector view.
2140
+ * @param[in] config Configuration options for this specific operation.
2141
+ */
2142
+ add_result_t update(id_t old_id, label_t label, vector_view_t vector, add_config_t config = {}) usearch_noexcept_m {
2143
+
2144
+ usearch_assert_m(!is_immutable(), "Can't add to an immutable index");
2145
+ add_result_t result;
2146
+
2147
+ // Make sure we have enough local memory to perform this request
2148
+ context_t& context = contexts_[config.thread];
2149
+ top_candidates_t& top = context.top_candidates;
2150
+ next_candidates_t& next = context.next_candidates;
2151
+ top.clear();
2152
+ next.clear();
2153
+
2154
+ // The top list needs one more slot than the connectivity of the base level
2155
+ // for the heuristic, that tries to squeeze one more element into saturated list.
2156
+ std::size_t top_limit = (std::max)(base_level_multiple_() * config_.connectivity + 1, config.expansion);
2157
+ if (!top.reserve(top_limit))
2158
+ return result.failed("Out of memory!");
2159
+ if (!next.reserve(config.expansion))
2160
+ return result.failed("Out of memory!");
2161
+
2162
+ node_lock_t new_lock = node_lock_(old_id);
2163
+ node_t node = node_with_id_(old_id);
2164
+
2165
+ // Pull stats
2166
+ result.measurements = context.measurements_count;
2167
+ result.cycles = context.iteration_cycles;
2168
+
2169
+ connect_node_across_levels_(old_id, vector, entry_id_, max_level_, node.level(), config, context);
2170
+ node.label(label);
2171
+
2172
+ // Normalize stats
2173
+ result.measurements = context.measurements_count - result.measurements;
2174
+ result.cycles = context.iteration_cycles - result.cycles;
2175
+
2176
+ return result;
2177
+ }
2178
+
2079
2179
  /**
2080
2180
  * @brief Searches for the closest elements to the given ::query. Thread-safe.
2081
2181
  *
@@ -2128,40 +2228,6 @@ class index_gt {
2128
2228
  return result;
2129
2229
  }
2130
2230
 
2131
- template <typename predicate_at = dummy_predicate_t>
2132
- search_result_t search_around( //
2133
- id_t hint, vector_view_t query, std::size_t wanted, //
2134
- search_config_t config = {}, predicate_at&& predicate = dummy_predicate_t{}) const noexcept {
2135
-
2136
- context_t& context = contexts_[config.thread];
2137
- top_candidates_t& top = context.top_candidates;
2138
- next_candidates_t& next = context.next_candidates;
2139
- search_result_t result{*this, top};
2140
-
2141
- if (!size_)
2142
- return result;
2143
-
2144
- std::size_t expansion = (std::max)(config.expansion, wanted);
2145
- if (!next.reserve(expansion))
2146
- return result.failed("Out of memory!");
2147
- if (!top.reserve(expansion))
2148
- return result.failed("Out of memory!");
2149
-
2150
- // Go down the level, tracking only the closest match
2151
- result.measurements = context.measurements_count;
2152
- result.cycles = context.iteration_cycles;
2153
-
2154
- search_to_find_in_base_(hint, query, expansion, context, std::forward<predicate_at>(predicate));
2155
- top.sort_ascending();
2156
- top.shrink(wanted);
2157
-
2158
- // Normalize stats
2159
- result.measurements = context.measurements_count - result.measurements;
2160
- result.cycles = context.iteration_cycles - result.cycles;
2161
- result.count = top.size();
2162
- return result;
2163
- }
2164
-
2165
2231
  #pragma endregion
2166
2232
 
2167
2233
  #pragma region Metadata
@@ -2222,7 +2288,7 @@ class index_gt {
2222
2288
  }
2223
2289
 
2224
2290
  // Temporary data-structures, proportional to the number of nodes:
2225
- total += limits_.elements * sizeof(node_t) + allocator_entry_bytes;
2291
+ total += limits_.members * sizeof(node_t) + allocator_entry_bytes;
2226
2292
 
2227
2293
  // Temporary data-structures, proportional to the number of threads:
2228
2294
  total += limits_.threads() * sizeof(context_t) + allocator_entry_bytes * 3;
@@ -2377,7 +2443,7 @@ class index_gt {
2377
2443
  pre_ = precompute_(config_);
2378
2444
 
2379
2445
  index_limits_t limits;
2380
- limits.elements = state.size;
2446
+ limits.members = state.size;
2381
2447
  if (!reserve(limits)) {
2382
2448
  std::fclose(file);
2383
2449
  return result.failed("Out of memory");
@@ -2497,7 +2563,7 @@ class index_gt {
2497
2563
  pre_ = precompute_(config_);
2498
2564
 
2499
2565
  index_limits_t limits;
2500
- limits.elements = state.size;
2566
+ limits.members = state.size;
2501
2567
  limits.threads_add = 0;
2502
2568
  if (!reserve(limits))
2503
2569
  return result.failed("Out of memory!");
@@ -2545,6 +2611,11 @@ class index_gt {
2545
2611
  * @brief Adapts the Male-Optimal Stable Marriage algorithm for unequal sets
2546
2612
  * to perform fast one-to-one matching between two large collections
2547
2613
  * of vectors, using approximate nearest neighbors search.
2614
+ *
2615
+ * @param[inout] first_to_second Container to map ::first labels to ::second.
2616
+ * @param[inout] second_to_first Container to map ::second labels to ::first.
2617
+ * @param[in] executor Thread-pool to execute the job in parallel.
2618
+ * @param[in] progress Callback to report the execution progress.
2548
2619
  */
2549
2620
  template < //
2550
2621
  typename first_to_second_at = dummy_label_to_label_mapping_t, //
@@ -2578,6 +2649,44 @@ class index_gt {
2578
2649
  std::forward<progress_at>(progress));
2579
2650
  }
2580
2651
 
2652
+ /**
2653
+ * @brief Scans the whole collection, removing the links leading towards
2654
+ * banned entries. This essentially isolates some nodes from the rest
2655
+ * of the graph, while keeping their outgoing links, in case the node
2656
+ * is structurally relevant and has a crucial role in the index.
2657
+ * It won't reclaim the memory.
2658
+ *
2659
+ * @param[in] allow_member Predicate to mark nodes for isolation.
2660
+ * @param[in] executor Thread-pool to execute the job in parallel.
2661
+ * @param[in] progress Callback to report the execution progress.
2662
+ */
2663
+ template < //
2664
+ typename allow_member_at = dummy_predicate_t, //
2665
+ typename executor_at = dummy_executor_t, //
2666
+ typename progress_at = dummy_progress_t //
2667
+ >
2668
+ void isolate( //
2669
+ allow_member_at&& allow_member, //
2670
+ executor_at&& executor = executor_at{}, //
2671
+ progress_at&& progress = progress_at{}) noexcept {
2672
+
2673
+ // Erase all the incoming links
2674
+ executor.execute_bulk(size(), [&](std::size_t node_idx, std::size_t) {
2675
+ node_t node = node_with_id_(node_idx);
2676
+ for (level_t level = 0; level <= node.level(); ++level) {
2677
+ neighbors_ref_t neighbors = neighbors_(node, level);
2678
+ std::size_t old_size = neighbors.size();
2679
+ neighbors.clear();
2680
+ for (std::size_t i = 0; i != old_size; ++i) {
2681
+ id_t neighbor_id = neighbors[i];
2682
+ node_t neighbor = node_with_id_(neighbor_id);
2683
+ if (allow_member(member_cref_t{neighbor.label(), neighbor.vector_view(), neighbor_id}))
2684
+ neighbors.push_back(neighbor_id);
2685
+ }
2686
+ }
2687
+ });
2688
+ }
2689
+
2581
2690
  private:
2582
2691
  template <typename first_to_second_at, typename second_to_first_at, typename executor_at, typename progress_at>
2583
2692
  static join_result_t join_small_and_big_( //
@@ -2605,7 +2714,7 @@ class index_gt {
2605
2714
  // free men will be added/pulled.
2606
2715
  std::mutex free_men_mutex{};
2607
2716
  ring_gt<id_t, ids_allocator_t> free_men;
2608
- free_men.resize(men.size());
2717
+ free_men.reserve(men.size());
2609
2718
  for (std::size_t i = 0; i != men.size(); ++i)
2610
2719
  free_men.push(static_cast<id_t>(i));
2611
2720
 
@@ -2872,6 +2981,23 @@ class index_gt {
2872
2981
  return {nodes_mutexes_, idx};
2873
2982
  }
2874
2983
 
2984
+ void connect_node_across_levels_( //
2985
+ id_t node_id, vector_view_t vector, //
2986
+ id_t entry_id, level_t max_level, level_t target_level, //
2987
+ add_config_t const& config, context_t& context) usearch_noexcept_m {
2988
+
2989
+ // Go down the level, tracking only the closest match
2990
+ id_t closest_id = search_for_one_(entry_id, vector, max_level, target_level, context);
2991
+
2992
+ // From `target_level` down perform proper extensive search
2993
+ for (level_t level = (std::min)(target_level, max_level); level >= 0; --level) {
2994
+ // TODO: Handle out of memory conditions
2995
+ search_to_insert_(closest_id, vector, level, config.expansion, context);
2996
+ closest_id = connect_new_node_(node_id, level, context);
2997
+ reconnect_neighbor_nodes_(node_id, level, context);
2998
+ }
2999
+ }
3000
+
2875
3001
  id_t connect_new_node_(id_t new_id, level_t level, context_t& context) usearch_noexcept_m {
2876
3002
 
2877
3003
  node_t new_node = node_with_id_(new_id);