Skip to content

Commit aabc44d

Browse files
Fix: treat NumPy uint8 vectors as u8, not bit-packed binary
A NumPy uint8 buffer is ambiguous: the same bytes back both bit-packed binary vectors (b1x8) and unsigned byte vectors (u8). numpy_string_to_kind maps uint8 to b1x8 for backwards compatibility, so when no explicit dtype was passed to add/search, a u8 index reinterpreted its data as packed bits and the vectors silently collapsed to (near-)zero. int8 worked because it maps to i8 unambiguously. Disambiguate against the index's own scalar kind: when the caller gave no dtype and the buffer resolves to b1x8 but the index is u8, use u8. Binary indexes are unaffected, so bit-packed uint8 vectors keep working. Applied at the add, search, and cluster dispatch sites through a shared helper, and added a scalar_kind() accessor to the multi-shard wrapper so it compiles for both index types. Adds a regression test that stores sparse uint8 counts, checks the round trip and search ranking, and confirms the binary path is untouched. The test fails on the current code (vector stored as all zeros) and passes with the fix. Closes #595
1 parent cc23bba commit aabc44d

2 files changed

Lines changed: 65 additions & 9 deletions

File tree

python/lib.cpp

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ struct dense_indexes_py_t {
9797
void merge(std::shared_ptr<dense_index_py_t> shard) { shards_.push_back(shard); }
9898
std::size_t bytes_per_vector() const noexcept { return shards_.empty() ? 0 : shards_[0]->bytes_per_vector(); }
9999
std::size_t scalar_words() const noexcept { return shards_.empty() ? 0 : shards_[0]->scalar_words(); }
100+
scalar_kind_t scalar_kind() const noexcept {
101+
return shards_.empty() ? scalar_kind_t::unknown_k : shards_[0]->scalar_kind();
102+
}
100103
index_limits_t limits() const noexcept { return {size(), std::numeric_limits<std::size_t>::max()}; }
101104

102105
void merge_paths(std::vector<std::string> const& paths, bool view = true, std::size_t threads = 0) {
@@ -180,6 +183,24 @@ scalar_kind_t numpy_string_to_kind(std::string const& name) {
180183
return scalar_kind_t::unknown_k;
181184
}
182185

186+
/// @brief Resolves the scalar kind of a NumPy buffer for a specific index.
187+
///
188+
/// NumPy `uint8` buffers are ambiguous: the same bytes back both bit-packed
189+
/// binary vectors (`b1x8`) and unsigned byte vectors (`u8`), and
190+
/// `numpy_string_to_kind` maps `uint8` to `b1x8` for backwards compatibility.
191+
/// When the caller passed no explicit `scalar_kind`, disambiguate against the
192+
/// index's own scalar kind, so a `u8` index isn't fed its data as packed bits
193+
/// (which silently zeroed out the vectors). See issue #595.
194+
template <typename index_at>
195+
scalar_kind_t resolve_buffer_kind(scalar_kind_t requested, py::buffer_info const& buffer_info, index_at const& index) {
196+
if (requested != scalar_kind_t::unknown_k)
197+
return requested;
198+
scalar_kind_t detected = numpy_string_to_kind(buffer_info.format);
199+
if (detected == scalar_kind_t::b1x8_k && index.scalar_kind() == scalar_kind_t::u8_k)
200+
return scalar_kind_t::u8_k;
201+
return detected;
202+
}
203+
183204
template <typename result_at> void forward_error(result_at&& result) {
184205

185206
if (!result)
@@ -286,9 +307,7 @@ static void add_many_to_index( //
286307
// kind here.
287308

288309
// clang-format off
289-
scalar_kind_t kind = (scalar_kind != scalar_kind_t::unknown_k)
290-
? scalar_kind
291-
: numpy_string_to_kind(vectors_info.format);
310+
scalar_kind_t kind = resolve_buffer_kind(scalar_kind, vectors_info, index);
292311
switch (kind) {
293312
case scalar_kind_t::f64_k: add_typed_to_index<f64_t>(index, keys_info, vectors_info, force_copy, threads, progress); break;
294313
case scalar_kind_t::f32_k: add_typed_to_index<f32_t>(index, keys_info, vectors_info, force_copy, threads, progress); break;
@@ -501,9 +520,7 @@ static py::tuple search_many_in_index( //
501520
std::atomic<std::size_t> stats_computed_distances(0);
502521

503522
// clang-format off
504-
scalar_kind_t kind = (scalar_kind != scalar_kind_t::unknown_k)
505-
? scalar_kind
506-
: numpy_string_to_kind(vectors_info.format);
523+
scalar_kind_t kind = resolve_buffer_kind(scalar_kind, vectors_info, index);
507524
switch (kind) {
508525
case scalar_kind_t::f64_k: search_typed<f64_t>(index, vectors_info, wanted, exact, threads, keys_py, distances_py, counts_py, stats_visited_members, stats_computed_distances, progress); break;
509526
case scalar_kind_t::f32_k: search_typed<f32_t>(index, vectors_info, wanted, exact, threads, keys_py, distances_py, counts_py, stats_visited_members, stats_computed_distances, progress); break;
@@ -780,9 +797,7 @@ static py::tuple cluster_vectors( //
780797
rows_lookup_gt<byte_t const> queries_end = queries_begin + queries_count;
781798

782799
// clang-format off
783-
scalar_kind_t kind = (scalar_kind != scalar_kind_t::unknown_k)
784-
? scalar_kind
785-
: numpy_string_to_kind(queries_info.format);
800+
scalar_kind_t kind = resolve_buffer_kind(scalar_kind, queries_info, index);
786801
{
787802
py::gil_scoped_release release;
788803
std::unique_lock<std::mutex> lock(*index.mutex_ptr_);

python/scripts/test_index.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,47 @@ def test_index_get_missing_keys(multi):
165165
assert index.get(1) is None
166166

167167

168+
def test_u8_vectors_not_misread_as_binary():
169+
"""`uint8` vectors must be stored as bytes, not bit-packed binary (#595).
170+
171+
A NumPy `uint8` buffer is ambiguous: the same bytes back both bit-packed
172+
binary vectors (`b1x8`) and unsigned byte vectors (`u8`). When no explicit
173+
`dtype` is passed to `add`/`search`, the buffer used to resolve to `b1x8`,
174+
so a `u8` index silently reinterpreted its data as packed bits and collapsed
175+
the vectors to (near-)zero. The index's own scalar kind must disambiguate.
176+
"""
177+
reset_randomness()
178+
ndim = 8
179+
# Sparse small counts, like the reporter's feature-count vectors.
180+
vector = np.zeros(ndim, dtype=np.uint8)
181+
vector[2] = 3
182+
vector[5] = 7
183+
184+
index = Index(ndim=ndim, metric=MetricKind.L2sq, dtype=ScalarKind.U8)
185+
index.add(0, vector) # no explicit dtype: the previously-broken path
186+
187+
# Round-trip: the stored bytes must match the input, not zeros.
188+
stored = index.get(0, ScalarKind.U8)
189+
assert np.array_equal(stored, vector), f"u8 vector corrupted on add: {stored}"
190+
191+
# Search must be coherent: the exact vector is its own nearest neighbor at
192+
# distance 0, and a far vector ranks strictly behind it.
193+
far = np.zeros(ndim, dtype=np.uint8)
194+
far[0] = 200
195+
far[7] = 200
196+
index.add(1, far)
197+
matches = index.search(vector, 2)
198+
assert matches.keys[0] == 0
199+
assert float(matches.distances[0]) == 0.0
200+
assert float(matches.distances[1]) > 0.0
201+
202+
# The fix must not touch binary indexes: uint8 still means bit-packed there.
203+
binary = Index(ndim=64, metric=MetricKind.Hamming, dtype=ScalarKind.B1)
204+
packed = np.array([0b10101010] * 8, dtype=np.uint8)
205+
binary.add(0, packed)
206+
assert float(binary.search(packed, 1).distances[0]) == 0.0
207+
208+
168209
@pytest.mark.parametrize("ndim", [3, 97, 256])
169210
@pytest.mark.parametrize("metric", [MetricKind.Cos, MetricKind.L2sq])
170211
@pytest.mark.parametrize("batch_size", [1, 7, 1024])

0 commit comments

Comments
 (0)