diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6b19f882..4c522eb1 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,9 +1,10 @@ name: coverage -# Measure test coverage with cargo-llvm-cov and upload to Codecov (README badge). -# Informational, not a required status check. Tokenless upload (supported for -# public repos); fail_ci_if_error is false so a failed/absent upload never blocks -# a PR. Activate the repo at https://codecov.io to surface the dashboard + badge. +# Measure test coverage with cargo-llvm-cov (enforcing a regression floor in the +# same --all-features run) and upload to Codecov for the README badge. The upload +# is informational — fail_ci_if_error is false, so a failed or absent upload never +# blocks a PR. Activate the repo at https://codecov.io to surface the dashboard + +# badge. on: push: branches: [main] @@ -37,8 +38,14 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: Install cargo-llvm-cov (pinned) run: cargo install cargo-llvm-cov --version 0.8.7 --locked - - name: Generate coverage (lcov) - run: cargo llvm-cov --all-features --lcov --output-path lcov.info + # Generate the lcov report AND enforce the regression floor in a single + # --all-features invocation, so the floor is computed on exactly the data + # that gets uploaded — no separate `report` step that could differ in + # feature/package selection. On a regression this command exits non-zero + # (so the upload below is skipped). The floor sits well under the actual + # line coverage (~89%); raise it as coverage grows. + - name: Generate coverage (lcov) + enforce floor + run: cargo llvm-cov --all-features --fail-under-lines 85 --lcov --output-path lcov.info - name: Upload coverage to Codecov uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: diff --git a/ordvec-python/tests/test_redteam_fuzz.py b/ordvec-python/tests/test_redteam_fuzz.py new file mode 100644 index 00000000..9ec151ce --- /dev/null +++ b/ordvec-python/tests/test_redteam_fuzz.py @@ -0,0 +1,1110 @@ +"""Adversarial / red-team fuzz of the ordvec FFI boundary (callsign: Cipher). + +Goal: feed the Python API garbage and confirm that *every* malformed input +surfaces as a clean, typed Python exception (``ValueError`` / ``IndexError`` / +``TypeError`` / ``IOError``) and never as a ``pyo3_runtime.PanicException``, a +hard interpreter abort / segfault, an OOM/hang, or a silently wrong result. + +This file is the offensive complement to ``test_input_guards.py``: that suite +pins the three documented guard classes (non-finite, non-contiguous, OOR +subset ids); this one goes after the corners those tests do *not* touch — + +* integer-scalar abuse for ``k`` / ``m`` / ``batch_size`` / ``idx`` / + candidate ids: negative, ``2**63``, ``2**64`` (the wrap-to-giant-usize → + OOM hypothesis); +* the ``from_shape_vec`` reshape and ``m_eff`` flatten invariants under + adversarial ``m`` / ``k`` (the ``debug_assert_eq!`` in the batched flatten is + compiled out in ``--release``, so a row-width mismatch would ``.expect()``-panic + there — these tests assert it never does); +* the four on-disk loaders against truncated / extended / forged / corrupt + files and a forged-huge-dim DoS-allocation header; +* exotic dtypes (bool / float16 / object / complex / int families) and NaN bit + patterns (signaling + quiet) across every f32 entry point; +* type confusion on the ``search_asymmetric_byte_lut`` ``PyRef`` arg + and on every ``None`` / list / str argument; +* the documented PyO3 borrow-flag reentrancy contract (a ``__index__`` callback + that re-enters a ``&mut self`` method on the object a ``&self`` method already + borrowed → clean ``Already borrowed`` ``RuntimeError``, never a data race). + +Abort-class probes (anything that *could* crash the interpreter rather than +raise) are run in a child process via ``_run_isolated`` so a hypothetical +segfault is observed as a non-zero child exit code instead of taking the whole +pytest session down with it. + +Findings: the guards held on every vector probed — no genuine bug was found, so +every test here asserts CORRECT (guarded) behavior and passes. Any test that +*would* document a real defect is marked ``@pytest.mark.skip(reason="BUG: …")``; +there are currently none. See ``/tmp/cipher/py/findings.md`` for the full report. +""" +from __future__ import annotations + +import math +import os +import struct +import subprocess +import sys +import tempfile + +import numpy as np +import pytest + +from ordvec import ( + Bitmap, + Rank, + RankQuant, + SignBitmap, + bucket_centre, + bucket_ranks, + pack_buckets, + rank_norm, + rank_to_bucket, + rank_transform, + rankquant_bytes_per_vec, + rankquant_norm, + search_asymmetric_byte_lut, + unpack_buckets, +) + + +def unit_vectors(n: int, dim: int, seed: int = 0) -> np.ndarray: + rng = np.random.default_rng(seed) + v = rng.standard_normal((n, dim)).astype(np.float32) + v /= np.linalg.norm(v, axis=1, keepdims=True) + 1e-9 + return v + + +# NaN bit patterns numpy/Rust must both treat as non-finite. Quiet NaN, signaling +# NaN, and a negative signaling NaN — `f32::is_finite()` must reject all three. +_QNAN = float(np.array([0x7FC00000], dtype=np.uint32).view(np.float32)[0]) +_SNAN = float(np.array([0x7FA00000], dtype=np.uint32).view(np.float32)[0]) +_NEG_SNAN = float(np.array([0xFFA00000], dtype=np.uint32).view(np.float32)[0]) + +# dtypes rust-numpy must reject for an f32 array param (strict, no coercion). +_WRONG_F32_DTYPES = [ + np.float64, + np.float16, + np.int32, + np.int64, + np.uint8, + np.uint32, + bool, + np.complex64, + object, +] + +# Integer scalars that must NOT wrap to a giant usize / OOM. PyO3 maps a negative +# Python int and anything >= 2**64 to a clean OverflowError on usize conversion. +_BAD_INT_SCALARS = [-1, -(2**40), 2**64, 2**70] +# Huge-but-valid usize values that the core must CLAMP (to k<=n / m<=n), never +# allocate eagerly. usize::MAX is 2**64-1 on 64-bit but only 2**32-1 on 32-bit, so +# a 2**40+ literal would raise OverflowError at the PyO3 usize conversion on a +# 32-bit target (before reaching the clamp). Pick values that fit usize on each +# target so the clamp path is what's exercised everywhere. +_64BIT = sys.maxsize > 2**32 +_HUGE_VALID_USIZE = [2**40, 2**62, 2**63] if _64BIT else [2**30, 2**31] +# m-sweep lists for the batched/chunked flatten-invariant tests (same rationale). +_HUGE_M = [0, 1, 1000, 2**40, 2**62] if _64BIT else [0, 1, 1000, 2**30, 2**31] +_HUGE_M_MID = [0, 1, 1000, 2**62] if _64BIT else [0, 1, 1000, 2**31] +_HUGE_M_SIMPLE = [0, 1, 2**62] if _64BIT else [0, 1, 2**31] + + +# ===================================================================== +# Subprocess-isolation harness for abort-class probes. +# A segfault/abort cannot be caught by pytest.raises (it kills the process), +# so run the probe in a child and assert it exits 0 (clean) — a crash shows up +# as a negative return code (terminating signal) or a non-zero exit. +# ===================================================================== + +_CHILD_PREAMBLE = ( + "import numpy as np\n" + "from ordvec import Rank, RankQuant, Bitmap, SignBitmap\n" + "def uv(n,d,s=0):\n" + " rng=np.random.default_rng(s); v=rng.standard_normal((n,d)).astype(np.float32)\n" + " v/=np.linalg.norm(v,axis=1,keepdims=True)+1e-9; return v\n" +) + + +def _run_isolated(body: str) -> subprocess.CompletedProcess: + """Run a probe body in a child interpreter; return the completed process. + + The child prints ``OK`` on a clean finish. The caller asserts + ``returncode == 0`` so a hard abort (negative rc = killed by signal) or an + uncaught ``PanicException`` (rc = 1, traceback on stderr) fails loudly here + instead of crashing the pytest session. + """ + src = _CHILD_PREAMBLE + body + "\nprint('OK')\n" + return subprocess.run( + [sys.executable, "-c", src], + capture_output=True, + text=True, + timeout=30, + ) + + +def _assert_clean_child(proc: subprocess.CompletedProcess) -> None: + assert proc.returncode == 0, ( + f"child crashed: rc={proc.returncode} " + f"(negative = killed by signal {-proc.returncode} → abort/segfault)\n" + f"stderr:\n{proc.stderr}" + ) + assert "PanicException" not in proc.stderr, ( + f"core panic leaked across the FFI boundary:\n{proc.stderr}" + ) + assert proc.stdout.strip().endswith("OK"), ( + f"child did not finish cleanly:\nstdout:{proc.stdout}\nstderr:{proc.stderr}" + ) + + +# ===================================================================== +# KEY PROBE: integer-scalar abuse for k / m / batch_size / idx / candidate ids. +# A negative or >= 2**64 Python int must raise a clean OverflowError, never wrap +# to a giant usize that triggers an OOM or an OOB panic in the core. +# ===================================================================== + + +@pytest.mark.parametrize("bad_k", _BAD_INT_SCALARS) +def test_rank_search_bad_int_k_raises_overflow(bad_k): + idx = Rank(dim=64) + idx.add(unit_vectors(10, 64)) + with pytest.raises(OverflowError): + idx.search(unit_vectors(2, 64, seed=1), k=bad_k) + + +@pytest.mark.parametrize("bad_k", _BAD_INT_SCALARS) +def test_rankquant_search_asym_bad_int_k_raises_overflow(bad_k): + idx = RankQuant(dim=64, bits=2) + idx.add(unit_vectors(10, 64)) + with pytest.raises(OverflowError): + idx.search_asymmetric(unit_vectors(2, 64, seed=1), k=bad_k) + + +@pytest.mark.parametrize("bad_m", _BAD_INT_SCALARS) +def test_bitmap_top_m_bad_int_m_raises_overflow(bad_m): + idx = Bitmap(dim=64, n_top=8) + idx.add(unit_vectors(20, 64)) + with pytest.raises(OverflowError): + idx.top_m_candidates(unit_vectors(1, 64, seed=1)[0], m=bad_m) + + +@pytest.mark.parametrize("bad", _BAD_INT_SCALARS) +def test_bitmap_chunked_bad_int_batch_size_raises_overflow(bad): + idx = Bitmap(dim=64, n_top=8) + idx.add(unit_vectors(20, 64)) + q = unit_vectors(2, 64, seed=1) + with pytest.raises(OverflowError): + idx.top_m_candidates_batched_chunked(q, m=5, batch_size=bad) + + +@pytest.mark.parametrize("bad_idx", _BAD_INT_SCALARS) +def test_swap_remove_bad_int_idx_raises_overflow(bad_idx): + idx = Rank(dim=64) + idx.add(unit_vectors(5, 64)) + with pytest.raises(OverflowError): + idx.swap_remove(bad_idx) + + +@pytest.mark.parametrize("bad_k", _BAD_INT_SCALARS) +def test_subset_bad_int_k_raises_overflow(bad_k): + idx = RankQuant(dim=64, bits=2) + idx.add(unit_vectors(10, 64)) + cand = np.array([0, 1, 2], dtype=np.uint32) + with pytest.raises(OverflowError): + idx.search_asymmetric_subset(unit_vectors(1, 64, seed=1)[0], cand, k=bad_k) + + +@pytest.mark.parametrize("huge_k", _HUGE_VALID_USIZE) +def test_rank_search_huge_valid_k_clamps_not_ooms(huge_k): + # A huge-but-valid usize k must be CLAMPED to the index size — the result + # has min(k, n) columns and is computed without eagerly allocating k slots. + idx = Rank(dim=64) + idx.add(unit_vectors(10, 64)) + scores, indices = idx.search(unit_vectors(2, 64, seed=1), k=huge_k) + assert scores.shape == (2, 10) # clamped to n=10 + assert indices.shape == (2, 10) + assert np.isfinite(scores).all() + + +@pytest.mark.parametrize("huge_m", _HUGE_VALID_USIZE) +def test_bitmap_top_m_huge_valid_m_clamps_not_ooms(huge_m): + idx = Bitmap(dim=64, n_top=8) + idx.add(unit_vectors(10, 64)) + cands = idx.top_m_candidates(unit_vectors(1, 64, seed=1)[0], m=huge_m) + assert cands.shape == (10,) # m_eff = min(m, n) + assert cands.dtype == np.uint32 + + +def test_rank_search_k_zero_returns_empty_columns(): + idx = Rank(dim=64) + idx.add(unit_vectors(10, 64)) + scores, indices = idx.search(unit_vectors(2, 64, seed=1), k=0) + assert scores.shape == (2, 0) + assert indices.shape == (2, 0) + + +# ===================================================================== +# Integer-scalar dtype: numpy int scalars / bool must convert via __index__; +# a float scalar (even integral-valued) must be rejected as TypeError. +# ===================================================================== + + +@pytest.mark.parametrize("k", [np.int64(3), np.uint64(3), np.int8(3), np.uint32(3), True]) +def test_search_accepts_integer_scalar_k(k): + idx = Rank(dim=64) + idx.add(unit_vectors(10, 64)) + scores, indices = idx.search(unit_vectors(1, 64, seed=1), k=k) + assert scores.shape == (1, int(k)) + + +@pytest.mark.parametrize("k", [np.float32(3.0), np.float64(3.0), 3.0]) +def test_search_rejects_float_scalar_k(k): + idx = Rank(dim=64) + idx.add(unit_vectors(10, 64)) + with pytest.raises(TypeError): + idx.search(unit_vectors(1, 64, seed=1), k=k) + + +# ===================================================================== +# dtype confusion: rust-numpy is strict — a wrong element dtype on any array +# param must be a clean TypeError (NOT a silent byte reinterpretation). +# ===================================================================== + + +@pytest.mark.parametrize("dt", _WRONG_F32_DTYPES) +def test_rank_add_wrong_dtype_raises_type_error(dt): + idx = Rank(dim=64) + bad = np.ones((4, 64), dtype=dt) + with pytest.raises(TypeError): + idx.add(bad) + + +@pytest.mark.parametrize("dt", [np.int64, np.uint8, np.int32, np.float32, np.uint64, np.int8]) +def test_subset_candidates_wrong_dtype_raises_type_error(dt): + # candidates must be uint32; int64/uint8/etc must not be reinterpreted. + idx = RankQuant(dim=64, bits=2) + idx.add(unit_vectors(10, 64)) + cand = np.array([0, 1, 2], dtype=dt) + with pytest.raises(TypeError): + idx.search_asymmetric_subset(unit_vectors(1, 64, seed=1)[0], cand, k=2) + + +@pytest.mark.parametrize("dt", [np.uint32, np.int64, np.float64, np.uint8]) +def test_body_overlap_q_bitmap_wrong_dtype_raises_type_error(dt): + # q_bitmap must be uint64; a narrower/float dtype must be a clean TypeError. + idx = Bitmap(dim=128, n_top=32) + idx.add(unit_vectors(10, 128)) + qb = idx.build_query_bitmap_fp32(unit_vectors(1, 128, seed=1)[0]).astype(dt) + with pytest.raises(TypeError): + idx.body_overlap_scores_subset(qb, np.array([0, 1], dtype=np.uint32)) + + +@pytest.mark.parametrize("dt", [np.int64, np.uint8, np.int32, np.uint64]) +def test_body_overlap_doc_ids_wrong_dtype_raises_type_error(dt): + idx = Bitmap(dim=128, n_top=32) + idx.add(unit_vectors(10, 128)) + qb = idx.build_query_bitmap_fp32(unit_vectors(1, 128, seed=1)[0]) + with pytest.raises(TypeError): + idx.body_overlap_scores_subset(qb, np.array([0, 1, 2], dtype=dt)) + + +def test_bucket_ranks_wrong_dtype_raises_type_error(): + # ranks must be uint16. + with pytest.raises(TypeError): + bucket_ranks(np.array([0, 1, 2, 3], dtype=np.int32), 2) + + +def test_pack_buckets_wrong_dtype_raises_type_error(): + with pytest.raises(TypeError): + pack_buckets(np.array([0, 1, 2, 3], dtype=np.int8), 2) + + +# ===================================================================== +# NaN encodings: signaling + quiet NaN bit patterns must both be rejected by +# the finite guard (f32::is_finite catches every NaN payload). +# ===================================================================== + + +@pytest.mark.parametrize("nan_val", [_QNAN, _SNAN, _NEG_SNAN, math.nan]) +def test_rank_add_all_nan_encodings_rejected(nan_val): + assert not np.isfinite(nan_val) + idx = Rank(dim=64) + v = unit_vectors(4, 64) + v[0, 0] = nan_val + with pytest.raises(ValueError, match="finite"): + idx.add(v) + + +@pytest.mark.parametrize("nan_val", [_QNAN, _SNAN, _NEG_SNAN]) +def test_signbitmap_build_query_nan_encodings_rejected(nan_val): + idx = SignBitmap(dim=64) + q = unit_vectors(1, 64, seed=1)[0] + q[3] = nan_val + with pytest.raises(ValueError, match="finite"): + idx.build_query_bitmap(q) + + +def test_rank_add_f32_extremes_are_accepted(): + # ±f32::MAX and the smallest subnormals are FINITE → must be accepted, not + # rejected by the finite guard (regression guard against an over-eager check). + big = np.finfo(np.float32).max + tiny = np.finfo(np.float32).smallest_subnormal + for fill in (big, -big, tiny, -tiny): + idx = Rank(dim=64) + v = unit_vectors(4, 64) + v[0, :] = fill + idx.add(v) # must not raise + assert len(idx) == 4 + + +# ===================================================================== +# Numeric-value correctness: sign threshold edge cases must match numpy exactly +# (a wrong-result bug would silently corrupt the sign-cosine candidate set). +# ===================================================================== + + +def test_signbitmap_zero_and_neg_zero_set_no_bits(): + # bit j is set iff coord_j > 0. 0.0 and -0.0 are NOT > 0, so popcount == 0. + idx = SignBitmap(dim=128) + for fill in (0.0, -0.0): + qb = idx.build_query_bitmap(np.full(128, fill, dtype=np.float32)) + popcount = sum(bin(int(w)).count("1") for w in qb) + assert popcount == 0, f"fill={fill!r} set {popcount} bits, expected 0" + + +def test_signbitmap_subnormal_sets_all_bits(): + # The smallest positive subnormal IS > 0 → every bit set (matches numpy). + idx = SignBitmap(dim=128) + tiny = np.finfo(np.float32).smallest_subnormal + qb = idx.build_query_bitmap(np.full(128, tiny, dtype=np.float32)) + popcount = sum(bin(int(w)).count("1") for w in qb) + assert popcount == 128 + assert tiny > 0 # numpy agrees + + +def test_signbitmap_build_query_matches_numpy_sign(): + # General parity: bit set iff q[j] > 0, byte-for-byte against numpy. + idx = SignBitmap(dim=128) + q = (np.arange(128, dtype=np.float32) - 64.0) # spans negative→positive→zero + qb = idx.build_query_bitmap(q) + popcount = sum(bin(int(w)).count("1") for w in qb) + assert popcount == int((q > 0.0).sum()) + + +def test_rank_all_equal_rows_tie_break_by_index(): + # Every coordinate equal → rank_transform ties broken by ascending index, so + # a self-query still scores ~1.0 and the index column is the identity order. + idx = Rank(dim=64) + idx.add(np.ones((5, 64), dtype=np.float32)) + scores, indices = idx.search(np.ones((1, 64), dtype=np.float32), k=3) + assert np.isfinite(scores).all() + assert indices[0].tolist() == [0, 1, 2] + + +def test_rank_transform_all_equal_is_identity_permutation(): + out = rank_transform(np.ones(8, dtype=np.float32)) + np.testing.assert_array_equal(out, np.arange(8, dtype=np.uint16)) + + +# ===================================================================== +# Shape abuse: wrong ndim must be a clean TypeError (rust-numpy enforces ndim). +# ===================================================================== + + +def test_rank_add_1d_where_2d_expected_raises_type_error(): + with pytest.raises(TypeError): + Rank(dim=64).add(unit_vectors(1, 64)[0]) # 1-D into a 2-D param + + +def test_rank_search_1d_where_2d_expected_raises_type_error(): + idx = Rank(dim=64) + idx.add(unit_vectors(10, 64)) + with pytest.raises(TypeError): + idx.search(unit_vectors(1, 64)[0], k=3) # 1-D query + + +def test_rank_add_0d_scalar_raises_type_error(): + with pytest.raises(TypeError): + Rank(dim=64).add(np.float32(1.0)) + + +def test_rank_add_3d_raises_type_error(): + with pytest.raises(TypeError): + Rank(dim=64).add(np.zeros((2, 3, 64), dtype=np.float32)) + + +def test_bitmap_top_m_2d_where_1d_expected_raises_type_error(): + idx = Bitmap(dim=64, n_top=8) + idx.add(unit_vectors(10, 64)) + with pytest.raises(TypeError): + idx.top_m_candidates(unit_vectors(2, 64), m=5) # 2-D into a 1-D param + + +def test_add_zero_rows_is_noop(): + idx = Rank(dim=64) + idx.add(np.empty((0, 64), dtype=np.float32)) + assert len(idx) == 0 + + +def test_add_zero_width_raises_value_error(): + # (64, 0): zero columns → width 0 != dim, caught by check_width. + with pytest.raises(ValueError, match="dimension"): + Rank(dim=64).add(np.empty((64, 0), dtype=np.float32)) + + +# ===================================================================== +# Non-contiguous views beyond the transpose/stride cases in test_input_guards: +# broadcast_to (0-stride), reversed, and a strided column-slice that has the +# RIGHT width but is non-contiguous — must hit the C-contiguity guard, never +# misread memory or return a wrong result. +# ===================================================================== + + +def test_rank_add_broadcast_to_zero_stride_raises_value_error(): + base = unit_vectors(1, 64)[0] + view = np.broadcast_to(base, (10, 64)) # 0-stride on axis 0, non-owning + assert not view.flags["C_CONTIGUOUS"] + with pytest.raises(ValueError, match="C-contiguous"): + Rank(dim=64).add(view) + + +def test_rank_add_reversed_view_raises_value_error(): + view = unit_vectors(4, 64)[::-1] + assert not view.flags["C_CONTIGUOUS"] + with pytest.raises(ValueError, match="C-contiguous"): + Rank(dim=64).add(view) + + +def test_rank_add_strided_columns_right_width_raises_value_error(): + # a[:, ::2] from a (4,128) array → shape (4,64): width MATCHES dim=64 but the + # buffer is non-contiguous. Must be rejected on contiguity, not silently read. + strided = unit_vectors(4, 128)[:, ::2] + assert strided.shape == (4, 64) + assert not strided.flags["C_CONTIGUOUS"] + with pytest.raises(ValueError, match="C-contiguous"): + Rank(dim=64).add(strided) + + +def test_signbitmap_batched_fortran_order_raises_value_error(): + idx = SignBitmap(dim=64) + idx.add(unit_vectors(10, 64)) + bad = np.asfortranarray(unit_vectors(4, 64)) + assert not bad.flags["C_CONTIGUOUS"] + with pytest.raises(ValueError, match="C-contiguous"): + idx.top_m_candidates_batched(bad, m=5) + + +# ===================================================================== +# Type confusion on non-array params: None / list / str must be a clean +# TypeError everywhere, including the search_asymmetric_byte_lut PyRef arg. +# ===================================================================== + + +@pytest.mark.parametrize("bad_first", [None, [1, 2, 3], "rq", 42]) +def test_byte_lut_wrong_index_type_raises_type_error(bad_first): + q = unit_vectors(2, 64) + with pytest.raises(TypeError): + search_asymmetric_byte_lut(bad_first, q, k=3) + + +def test_byte_lut_rank_instead_of_rankquant_raises_type_error(): + # A Rank (wrong index type) where RankQuant is required → TypeError, not a + # mis-cast that reads RankQuant fields off a Rank. + rk = Rank(dim=64) + rk.add(unit_vectors(10, 64)) + with pytest.raises(TypeError): + search_asymmetric_byte_lut(rk, unit_vectors(2, 64), k=3) + + +@pytest.mark.parametrize("bad", [None, [[1.0] * 64] * 4, "hello"]) +def test_rank_add_non_array_raises_type_error(bad): + with pytest.raises(TypeError): + Rank(dim=64).add(bad) + + +def test_subset_candidates_none_raises_type_error(): + idx = RankQuant(dim=64, bits=2) + idx.add(unit_vectors(10, 64)) + with pytest.raises(TypeError): + idx.search_asymmetric_subset(unit_vectors(1, 64)[0], None, k=2) + + +@pytest.mark.parametrize( + "ctor,args", + [ + (Rank, (None,)), + (Rank, (64.5,)), + (RankQuant, (None, 2)), + (RankQuant, (64, None)), + (RankQuant, (64.0, 2)), + (Bitmap, (64, None)), + (SignBitmap, (None,)), + ], +) +def test_constructors_reject_non_integer_args(ctor, args): + with pytest.raises(TypeError): + ctor(*args) + + +# ===================================================================== +# Constructor domain edges: huge / out-of-range dim & n_top → clean ValueError +# (or OverflowError for >= 2**64), never a deferred panic. +# ===================================================================== + + +def test_rank_dim_above_u16_value_error(): + with pytest.raises(ValueError, match=r"\[2, 65535\]"): + Rank(dim=65_536) + + +def test_rank_dim_2pow63_value_error(): + # 2**63 fits usize but is > u16::MAX → ValueError (not OverflowError). + with pytest.raises(ValueError, match=r"\[2, 65535\]"): + Rank(dim=2**63) + + +def test_rank_dim_2pow64_overflow_error(): + with pytest.raises(OverflowError): + Rank(dim=2**64) + + +def test_bitmap_huge_n_top_value_error(): + with pytest.raises(ValueError, match="n_top"): + Bitmap(dim=64, n_top=2**63) + + +@pytest.mark.parametrize( + "dim,bits,ok", + [ + (64, 1, True), # mult of 8 (codes_per_byte) — ok + (60, 1, False), # not a multiple of 8 + (2, 1, False), # below the 8-divisor + (4, 2, True), # min RankQuant dim for bits=2 (mult of 4) + (16, 4, True), # mult of 16 (= 2^4) + (8, 4, False), # not a multiple of 16 + ], +) +def test_rankquant_dim_bits_divisor_domain(dim, bits, ok): + if ok: + idx = RankQuant(dim=dim, bits=bits) + assert idx.dim == dim and idx.bits == bits + else: + with pytest.raises(ValueError, match="multiple"): + RankQuant(dim=dim, bits=bits) + + +def test_rankquant_min_dim_search_is_finite_and_shaped(): + # The smallest valid RankQuant must still produce a correct, finite result. + idx = RankQuant(dim=4, bits=2) + idx.add(unit_vectors(5, 4)) + scores, indices = idx.search_asymmetric(unit_vectors(3, 4, seed=1), k=2) + assert scores.shape == (3, 2) + assert indices.shape == (3, 2) + assert np.isfinite(scores).all() + + +# ===================================================================== +# Module-primitive bits/d domain edges. +# ===================================================================== + + +@pytest.mark.parametrize("bits", [8, 9, 255]) +def test_rank_to_bucket_bits_above_7_value_error(bits): + with pytest.raises(ValueError, match="bits"): + rank_to_bucket(0, 1024, bits) + + +def test_rank_to_bucket_d_zero_value_error(): + with pytest.raises(ValueError, match="d must be"): + rank_to_bucket(0, 0, 2) + + +@pytest.mark.parametrize("bits", [8, 255]) +def test_bucket_centre_bits_above_7_value_error(bits): + with pytest.raises(ValueError, match="bits"): + bucket_centre(0, bits) + + +def test_bucket_centre_out_of_range_bucket_value_error(): + # bucket 128 at bits=7 is one past the [0, 128) alphabet. + with pytest.raises(ValueError, match="out of range"): + bucket_centre(128, 7) + + +@pytest.mark.parametrize("bits", [0, 3, 5, 6, 7]) +def test_pack_buckets_non_124_bits_value_error(bits): + with pytest.raises(ValueError, match="bits"): + pack_buckets(np.zeros(8, dtype=np.uint8), bits) + + +def test_unpack_buckets_length_mismatch_value_error(): + with pytest.raises(ValueError, match="!= d"): + unpack_buckets(np.array([0, 0], dtype=np.uint8), 5, 2) + + +def test_rank_transform_length_above_u16_value_error(): + with pytest.raises(ValueError, match="u16"): + rank_transform(np.zeros(65_536, dtype=np.float32)) + + +def test_rank_transform_exactly_u16_max_ok(): + out = rank_transform(np.zeros(65_535, dtype=np.float32)) + assert out.shape == (65_535,) + assert out.dtype == np.uint16 + + +def test_primitive_pure_math_huge_d_no_panic(): + # rank_norm / rankquant_bytes_per_vec are pure arithmetic on a usize; a huge + # d must compute (or saturate) a value, never panic. Documents the surface. + assert rank_norm(2**60) > 0.0 + assert rankquant_bytes_per_vec(2**40, 2) == (2**40) * 2 // 8 + assert rankquant_norm(1024, 2) > 0.0 + + +# ===================================================================== +# Empty / boundary state: search before add, empty candidate sets, m_eff at n=0. +# ===================================================================== + + +def test_search_before_any_add_is_clean(): + # Each retrieval type must accept a search on an empty index and return a + # (nq, 0) / (0,)-shaped result, never panic on the from_shape_vec reshape. + q2 = unit_vectors(2, 64, seed=1) + q1 = unit_vectors(1, 64, seed=1)[0] + + s, i = Rank(dim=64).search(q2, k=5) + assert s.shape == (2, 0) and i.shape == (2, 0) + + s, i = RankQuant(dim=64, bits=2).search(q2, k=5) + assert s.shape == (2, 0) + s, i = RankQuant(dim=64, bits=2).search_asymmetric(q2, k=5) + assert s.shape == (2, 0) + + s, i = Bitmap(dim=64, n_top=8).search(q2, k=5) + assert s.shape == (2, 0) + assert Bitmap(dim=64, n_top=8).top_m_candidates(q1, m=5).shape == (0,) + + +def test_subset_on_empty_index_oor_candidate_index_error(): + idx = RankQuant(dim=64, bits=2) # n == 0 → every id is out of range + cand = np.array([0], dtype=np.uint32) + with pytest.raises(IndexError, match="out of range"): + idx.search_asymmetric_subset(unit_vectors(1, 64, seed=1)[0], cand, k=2) + + +def test_subset_empty_candidates_is_clean(): + idx = RankQuant(dim=64, bits=2) + idx.add(unit_vectors(10, 64)) + scores, ids = idx.search_asymmetric_subset( + unit_vectors(1, 64, seed=1)[0], np.array([], dtype=np.uint32), k=2 + ) + assert scores.shape == (0,) and ids.shape == (0,) + + +def test_body_overlap_empty_doc_ids_is_clean(): + idx = Bitmap(dim=128, n_top=32) + idx.add(unit_vectors(10, 128)) + qb = idx.build_query_bitmap_fp32(unit_vectors(1, 128, seed=1)[0]) + out = idx.body_overlap_scores_subset(qb, np.array([], dtype=np.uint32)) + assert out.shape == (0,) + + +def test_body_overlap_u32_max_doc_id_index_error(): + idx = Bitmap(dim=128, n_top=32) + idx.add(unit_vectors(10, 128)) + qb = idx.build_query_bitmap_fp32(unit_vectors(1, 128, seed=1)[0]) + with pytest.raises(IndexError, match="out of range"): + idx.body_overlap_scores_subset(qb, np.array([2**32 - 1], dtype=np.uint32)) + + +def test_body_overlap_sorted_duplicate_doc_ids_accepted(): + # Sorted ascending allows EQUAL adjacent ids (w[0] > w[1] is false for dups), + # and equal ids must score identically (correctness check). + idx = Bitmap(dim=128, n_top=32) + idx.add(unit_vectors(10, 128)) + qb = idx.build_query_bitmap_fp32(unit_vectors(1, 128, seed=1)[0]) + scores = idx.body_overlap_scores_subset(qb, np.array([1, 1, 2], dtype=np.uint32)) + assert scores.shape == (3,) + assert int(scores[0]) == int(scores[1]) # same id → same score + + +# ===================================================================== +# The m_eff flatten invariant under adversarial m (the debug_assert_eq! in the +# batched flatten is compiled out in --release; if a core row width ever != m_eff +# the from_shape_vec(...).expect(...) would panic). Sweep m around n on the +# DEBUG build here; the release build is swept separately via _run_isolated below. +# ===================================================================== + + +@pytest.mark.parametrize("n", [0, 1, 7, 50]) +@pytest.mark.parametrize("m", _HUGE_M) +def test_bitmap_batched_flatten_invariant_holds(n, m): + idx = Bitmap(dim=64, n_top=8) + if n: + idx.add(unit_vectors(n, 64)) + out = idx.top_m_candidates_batched(unit_vectors(3, 64, seed=1), m=m) + assert out.shape == (3, min(m, n)) + assert out.dtype == np.uint32 + + +@pytest.mark.parametrize("n", [0, 1, 7, 50]) +@pytest.mark.parametrize("m", _HUGE_M_MID) +def test_bitmap_chunked_flatten_invariant_holds(n, m): + idx = Bitmap(dim=64, n_top=8) + if n: + idx.add(unit_vectors(n, 64)) + out = idx.top_m_candidates_batched_chunked( + unit_vectors(3, 64, seed=1), m=m, batch_size=2 + ) + assert out.shape == (3, min(m, n)) + + +@pytest.mark.parametrize("n", [0, 1, 7, 50]) +@pytest.mark.parametrize("m", _HUGE_M_SIMPLE) +def test_signbitmap_batched_flatten_invariant_holds(n, m): + idx = SignBitmap(dim=64) + if n: + idx.add(unit_vectors(n, 64)) + out = idx.top_m_candidates_batched(unit_vectors(3, 64, seed=1), m=m) + assert out.shape == (3, min(m, n)) + + +# ===================================================================== +# Reentrancy: the documented PyO3 borrow-flag contract. A __index__ callback on +# an integer arg that re-enters a &mut self method (add / swap_remove) on the +# object a &self method (search / swap_remove) is already borrowing must raise a +# clean "Already borrowed" RuntimeError — NEVER a data race or panic. +# ===================================================================== + + +def test_reentrant_add_during_search_k_conversion_is_blocked(): + idx = Rank(dim=64) + idx.add(unit_vectors(10, 64)) + + class ReentrantK: + def __index__(self_inner): + idx.add(unit_vectors(2, 64)) # &mut self re-entry while search holds &self + return 3 + + with pytest.raises(RuntimeError, match="[Bb]orrowed"): + idx.search(unit_vectors(1, 64, seed=1), k=ReentrantK()) + assert len(idx) == 10 # the re-entrant mutation was cleanly blocked + + +def test_reentrant_add_during_swap_remove_idx_conversion_is_blocked(): + idx = Rank(dim=64) + idx.add(unit_vectors(10, 64)) + + class ReentrantIdx: + def __index__(self_inner): + idx.add(unit_vectors(2, 64)) # &mut self re-entry during swap_remove (&mut self) + return 0 + + with pytest.raises(RuntimeError, match="[Bb]orrowed"): + idx.swap_remove(ReentrantIdx()) + assert len(idx) == 10 + + +# ===================================================================== +# Loader corruption: write a real index, then truncate / extend / forge / corrupt +# the file and confirm load() raises a clean IOError (== OSError), never a panic +# or a DoS allocation. NB: IOError is OSError in Python 3, so the loader's +# io::Error → pyo3 PyIOError surfaces as catchable OSError. +# ===================================================================== + + +def _write_real_rank(path: str) -> bytes: + idx = Rank(dim=128) + idx.add(unit_vectors(20, 128)) + idx.write(path) + with open(path, "rb") as f: + return f.read() + + +def test_rank_load_header_only_truncated_io_error(tmp_path): + data = _write_real_rank(str(tmp_path / "real.tvr")) + p = str(tmp_path / "trunc.tvr") + with open(p, "wb") as f: + f.write(data[:13]) # header, zero payload + with pytest.raises(IOError): + Rank.load(p) + + +def test_rank_load_mid_payload_truncated_io_error(tmp_path): + data = _write_real_rank(str(tmp_path / "real.tvr")) + p = str(tmp_path / "half.tvr") + with open(p, "wb") as f: + f.write(data[: len(data) // 2]) + with pytest.raises(IOError): + Rank.load(p) + + +def test_rank_load_trailing_bytes_io_error(tmp_path): + # A structurally-valid file with extra trailing bytes is rejected (v1 has no + # footer) — guards against record-smuggling past a smaller declared payload. + data = _write_real_rank(str(tmp_path / "real.tvr")) + p = str(tmp_path / "ext.tvr") + with open(p, "wb") as f: + f.write(data + b"\x00" * 64) + with pytest.raises(IOError): + Rank.load(p) + + +def test_rank_load_forged_huge_n_vectors_io_error_no_oom(tmp_path): + # Forge n_vectors (bytes 9..13) to ~268M into a tiny file. The DoS-alloc + # hypothesis: a naive loader allocates n_vectors*dim*2 up front. The loader + # must reject (MAX_VECTORS / payload-mismatch) BEFORE allocating. + data = bytearray(_write_real_rank(str(tmp_path / "real.tvr"))) + data[9:13] = struct.pack(" 2**32 else 2**31 + proc = _run_isolated( + "bm = Bitmap(64, 8); bm.add(uv(50, 64))\n" + f"out = bm.top_m_candidates_batched(uv(3, 64), m={huge_m})\n" + "assert out.shape == (3, 50), out.shape\n" + ) + _assert_clean_child(proc) + + +def test_isolated_reentrant_borrow_no_abort(): + proc = _run_isolated( + "idx = Rank(64); idx.add(uv(10, 64))\n" + "class K:\n" + " def __index__(self):\n" + " idx.add(uv(2, 64)); return 3\n" + "try:\n" + " idx.search(uv(1, 64), k=K())\n" + " raise SystemExit('expected Already borrowed')\n" + "except RuntimeError:\n" + " pass\n" + "assert len(idx) == 10\n" + ) + _assert_clean_child(proc) diff --git a/tests/index/multi_bucket.rs b/tests/index/multi_bucket.rs index 922f4017..e9d7c485 100644 --- a/tests/index/multi_bucket.rs +++ b/tests/index/multi_bucket.rs @@ -66,3 +66,107 @@ fn multi_bucket_storage_matches_formula() { assert_eq!(mb2.bytes_per_vec(), D / 2); assert_eq!(mb4.bytes_per_vec(), D * 2); } + +/// `top_m_bilinear` is the candidate-generation primitive — it must return the +/// same top-`m` doc IDs (descending score) a brute-force scan of `bilinear_score` +/// over every doc would, and clamp `m` to the corpus size. Also covers the +/// `m == 0` early-return path. +#[test] +fn multi_bucket_top_m_bilinear_matches_bruteforce() { + let corpus = make_corpus(42); + let mut mb = MultiBucketBitmap::new(D, 2); + mb.add(&corpus); + let w = mb.outer_product_weights(); + + let mut rng = ChaCha8Rng::seed_from_u64(43); + let query: Vec = (0..D).map(|_| rng.random_range(-1.0..1.0)).collect(); + let q_bitmaps = mb.query_bitmaps_from_ranks(&query); + + // top_m_bilinear scores with the same kernel a brute-force scan would. + let score = |di: u32| mb.bilinear_score(&q_bitmaps, &w, di as usize); + + let m = 10; + let got = mb.top_m_bilinear(&q_bitmaps, &w, m); + assert_eq!(got.len(), m); + + // Tie-robust top-m correctness: every kept doc scores at least as high as + // every dropped doc. Bilinear scores are quantised onto the weight grid, so + // boundary ties are real — a set-equality check against a full sort would be + // fragile, but this boundary property holds regardless of tie-breaking. + let kept: std::collections::HashSet = got.iter().copied().collect(); + let min_kept = got + .iter() + .map(|&di| score(di)) + .fold(f32::INFINITY, f32::min); + for di in 0..N as u32 { + if !kept.contains(&di) { + assert!( + score(di) <= min_kept, + "doc {di} (score {}) was dropped but outscores the kept minimum {min_kept}", + score(di), + ); + } + } + + // Result is ordered by descending score. + let got_scores: Vec = got.iter().map(|&di| score(di)).collect(); + for pair in got_scores.windows(2) { + assert!(pair[0] >= pair[1], "top_m_bilinear not in descending order"); + } + + // m == 0 → empty (the early-return path); m > n_vectors clamps to n_vectors. + assert!(mb.top_m_bilinear(&q_bitmaps, &w, 0).is_empty()); + assert_eq!(mb.top_m_bilinear(&q_bitmaps, &w, N + 100).len(), N); +} + +/// A truncated (diagonal-only) weight matrix exercises the `weight == 0` skip +/// branch in `bilinear_score`: off-diagonal terms are zero and must be skipped, +/// leaving `Σ_a |Q_a ∩ D_a|` — the count of coordinates the query and doc place +/// in the same bucket. (Outer-product weights are never zero, so this path needs +/// a custom matrix — and diagonal/banded weights are the documented real use.) +#[test] +fn multi_bucket_bilinear_diagonal_weights_skip_zeros() { + let corpus = make_corpus(11); + let mut mb = MultiBucketBitmap::new(D, 2); + mb.add(&corpus); + let nb = mb.n_buckets(); + let mut w = vec![0.0f32; nb * nb]; + for a in 0..nb { + w[a * nb + a] = 1.0; + } + + let mut rng = ChaCha8Rng::seed_from_u64(12); + let query: Vec = (0..D).map(|_| rng.random_range(-1.0..1.0)).collect(); + let q_bitmaps = mb.query_bitmaps_from_ranks(&query); + + let q_buckets = bucket_ranks(&rank_transform(&query), 2); + for di in 0..std::cmp::min(8, N) { + let doc = &corpus[di * D..(di + 1) * D]; + let d_buckets = bucket_ranks(&rank_transform(doc), 2); + // Exact integer count (≤ D), representable in f32 with weight 1.0. + let same = (0..D).filter(|&j| q_buckets[j] == d_buckets[j]).count() as f32; + let got = mb.bilinear_score(&q_bitmaps, &w, di); + assert_eq!( + got, same, + "diagonal bilinear doc {di}: got {got}, want {same}" + ); + } +} + +/// Exercise the index accessors before and after `add`. +#[test] +fn multi_bucket_accessors() { + let mut mb = MultiBucketBitmap::new(D, 2); + assert_eq!(mb.dim(), D); + assert_eq!(mb.bits(), 2); + assert_eq!(mb.n_buckets(), 4); + assert_eq!(mb.len(), 0); + assert!(mb.is_empty()); + assert_eq!(mb.byte_size(), 0); + + mb.add(&make_corpus(7)); + assert_eq!(mb.len(), N); + assert!(!mb.is_empty()); + // byte_size = n_vectors * bytes_per_vec (bitmaps are u64 words). + assert_eq!(mb.byte_size(), N * mb.bytes_per_vec()); +} diff --git a/tests/redteam_delta.rs b/tests/redteam_delta.rs new file mode 100644 index 00000000..95b6da26 --- /dev/null +++ b/tests/redteam_delta.rs @@ -0,0 +1,701 @@ +//! Red-team hardening suite, fourth pass (`delta`). +//! +//! Adversarial pre-publication fuzzing of the `ordvec` public API, +//! authored by an offensive-security review. The earlier `alpha` / +//! `beta` / `gamma` suites pin specific historical fixes (SIMD-dispatch +//! lane invariants, the `k = usize::MAX` capacity-overflow clamp, the +//! subset out-of-range guard, the `rank_to_bucket` / `bucket_centre` +//! domain asserts). This suite covers the *remaining* boundary surface a +//! motivated attacker would poke at, and pins it as a regression so the +//! guarantees survive future refactors: +//! +//! - **DELTA-A (loaders, highest value).** Adversarial header geometry +//! the structural-fuzz in `tests/index/main.rs` does not cover: a +//! declared `n_vectors == MAX_VECTORS` paired with an empty file (the +//! "tiny header claims gigabytes" DoS — must reject in microseconds +//! without allocating), `n_vectors == MAX_VECTORS + 1`, `dim == 1` +//! (just under the `[2, MAX_DIM]` floor), per-format `dim` ceilings +//! (`MAX_DIM` for TVBM, `MAX_SIGN_BITMAP_DIM` for TVSB), bad version +//! bytes, and all-`0xFF` files at every header length. Every loader +//! must return `Err`, never panic / hang / OOM. +//! - **DELTA-B (integer overflow on 64-bit).** The symmetric `Rank` +//! search accumulates `Σ_d (2·q − (D−1))·(2·doc − (D−1))` into an +//! `i64`. At the true `dim = u16::MAX` ceiling with the rank extremes +//! this is the worst case; pin that it stays finite (no `i32`/`i64` +//! wrap, no `rank_norm` overflow) and the asymmetric path likewise. +//! - **DELTA-C (`search_asymmetric_subset` candidate list).** Empty list, +//! `k == 0`, `k > m`, duplicate ids (the same doc scored more than +//! once), and a duplicate-with-out-of-range id. The duplicate case is +//! the interesting one: it is *accepted* and the doc is returned once +//! per occurrence — documented here as the contract (the API does not +//! deduplicate), with the out-of-range guard still firing when a bad id +//! is mixed in. +//! - **DELTA-D (empty-index / empty-input search).** Search before any +//! `add`, `add(&[])`, `swap_remove` down to empty then search, +//! `swap_remove` + re-`add` (buffer integrity), `body_overlap_*` with +//! an empty `doc_ids` (and with an id against an empty index), and a +//! large `nq` × small `k` (the `result_buffer_len(nq, k)` axis). +//! - **DELTA-E (documented fail-loud contracts).** The bench-only +//! `search_asymmetric_byte_lut` panics on a `b = 1` index by design; +//! pin it so the documented restriction cannot silently regress into a +//! wrong-result path. +//! +//! Verdict of the pass: **no genuine bug found** — every probe confirmed +//! a guard holding (clean `Err`, intentional fail-loud, or correct +//! result). All tests below are passing assertions of correct behaviour; +//! none are `#[ignore]`d. + +use rand::{RngExt, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +use ordvec::rank::rank_norm; +use ordvec::{search_asymmetric_byte_lut, Bitmap, Rank, RankQuant, SignBitmap}; + +/// `MAX_VECTORS` from `rank_io` — the on-disk document-count ceiling. +/// Re-declared here (not imported) to keep the test independent of +/// whether the constant is re-exported; if the crate value ever changes, +/// the loader-rejection tests below still exercise the *boundary* +/// behaviour around whatever the loaders enforce. +const MAX_VECTORS_U32: u32 = 64 * 1024 * 1024; +/// `MAX_DIM` (= `u16::MAX`) and `MAX_SIGN_BITMAP_DIM` (= `1 << 24`). +const MAX_DIM_U32: u32 = u16::MAX as u32; +const MAX_SIGN_BITMAP_DIM_U32: u32 = 1 << 24; + +fn make_corpus(seed: u64, n: usize, dim: usize) -> Vec { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + (0..n * dim).map(|_| rng.random_range(-1.0..1.0)).collect() +} + +/// RAII guard that removes its temp file on drop, so a panicking test never +/// leaks a file in `$TMPDIR` (the per-test cleanup below is skipped if an +/// assertion fails first). Derefs / `AsRef`s to `Path`, so it passes straight to +/// the loaders. +struct TempFile(std::path::PathBuf); + +impl std::ops::Deref for TempFile { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TempFile { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// Write `bytes` to a uniquely-named temp file and return a self-deleting guard. +/// Mirrors the `forge` helper in `src/rank_io.rs`'s test module +/// (pid + nanosecond nonce + suffix) so concurrent test binaries never +/// collide, and uses only `std::fs` / `std::env::temp_dir` (no +/// `tempfile` dev-dependency). +fn forge(suffix: &str, bytes: &[u8]) -> TempFile { + let mut p = std::env::temp_dir(); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!( + "ordvec_redteam_delta_{}_{}_{}", + std::process::id(), + nonce, + suffix + )); + std::fs::write(&p, bytes).unwrap(); + TempFile(p) +} + +/// Run all four `T::load` entry points against one forged file and assert +/// each returns `Err` without panicking. `catch_unwind` enforces the +/// no-panic half of the contract (a malformed file must never abort the +/// process); `is_err` enforces the rejection half. +fn assert_all_loaders_reject(path: &std::path::Path, label: &str) { + let p = path.to_path_buf(); + let r1 = std::panic::catch_unwind(|| Rank::load(&p)); + assert!(r1.is_ok(), "Rank::load panicked on {label}"); + assert!(r1.unwrap().is_err(), "Rank::load accepted {label}"); + + let r2 = std::panic::catch_unwind(|| RankQuant::load(&p)); + assert!(r2.is_ok(), "RankQuant::load panicked on {label}"); + assert!(r2.unwrap().is_err(), "RankQuant::load accepted {label}"); + + let r3 = std::panic::catch_unwind(|| Bitmap::load(&p)); + assert!(r3.is_ok(), "Bitmap::load panicked on {label}"); + assert!(r3.unwrap().is_err(), "Bitmap::load accepted {label}"); + + let r4 = std::panic::catch_unwind(|| SignBitmap::load(&p)); + assert!(r4.is_ok(), "SignBitmap::load panicked on {label}"); + assert!(r4.unwrap().is_err(), "SignBitmap::load accepted {label}"); +} + +// ===================================================================== +// DELTA-A — loaders: adversarial header geometry. +// ===================================================================== + +/// DELTA-A1 (DoS): a forged TVR1 header declaring `n_vectors == +/// MAX_VECTORS` with a valid `dim` but **no payload bytes**. The implied +/// payload is `1024 * 64Mi * 2 ≈ 137 GiB`; a naive loader that sizes a +/// buffer from the declared length before checking it against the file +/// would attempt a 137 GiB allocation. `check_payload_matches_file` runs +/// *before* any allocation, so the loader must reject this in negligible +/// time. We bound the wall-clock to catch a regression that re-orders the +/// allocation ahead of the size check. +#[test] +fn delta_a1_loader_rejects_huge_declared_nvectors_with_empty_payload() { + let mut v = Vec::new(); + v.extend_from_slice(b"TVR1"); + v.push(1); // version + v.extend_from_slice(&1024u32.to_le_bytes()); // dim (valid) + v.extend_from_slice(&MAX_VECTORS_U32.to_le_bytes()); // n_vectors at the cap + // No payload bytes — declared payload ~137 GiB, file is header-only. + let p = forge("dos_huge_nvectors.tvr", &v); + + let start = std::time::Instant::now(); + let r = std::panic::catch_unwind(|| Rank::load(&p)); + let elapsed = start.elapsed(); + std::fs::remove_file(&p).ok(); + + assert!(r.is_ok(), "Rank::load panicked on the DoS header"); + assert!( + r.unwrap().is_err(), + "Rank::load must reject a header declaring a gigabyte payload over an empty file" + ); + // The size check is O(1) (a `stream_position` + integer compare), so this is + // a deliberately generous ceiling, not a perf assertion: it only needs to + // catch a regression that allocates ~137 GiB before the size check (which + // would OOM-kill or take far longer than this), and the wide margin keeps it + // from flaking on a loaded shared CI runner. + assert!( + elapsed < std::time::Duration::from_secs(30), + "loader took {elapsed:?} to reject a tiny-file/huge-payload header — \ + a size guard must precede allocation", + ); +} + +/// DELTA-A2: `n_vectors == MAX_VECTORS + 1` (one past the cap) must be +/// rejected by `check_n_vectors` for every format. The structural fuzz in +/// `main.rs` only exercises `u32::MAX`; this pins the exact boundary. +#[test] +fn delta_a2_loader_rejects_nvectors_one_past_max() { + let over = MAX_VECTORS_U32 + 1; + let mut v = Vec::new(); + v.extend_from_slice(b"TVR1"); + v.push(1); + v.extend_from_slice(&1024u32.to_le_bytes()); // dim valid + v.extend_from_slice(&over.to_le_bytes()); + let p = forge("nvectors_over_max.tvr", &v); + let r = std::panic::catch_unwind(|| Rank::load(&p)); + std::fs::remove_file(&p).ok(); + assert!(r.is_ok(), "Rank::load panicked on n_vectors = MAX+1"); + assert!( + r.unwrap().is_err(), + "Rank::load must reject n_vectors = MAX_VECTORS + 1" + ); +} + +/// DELTA-A3: `dim == 1` is one below the `[2, MAX_DIM]` floor enforced by +/// `check_dim`. A 1-dimensional rank vector is degenerate (the rank +/// transform / analytical norm assume `dim >= 2`), so the loader must +/// reject it — even though `dim == 1` would not, on its own, overflow any +/// size arithmetic. +#[test] +fn delta_a3_loader_rejects_dim_one() { + let mut v = Vec::new(); + v.extend_from_slice(b"TVR1"); + v.push(1); + v.extend_from_slice(&1u32.to_le_bytes()); // dim = 1 (< 2) + v.extend_from_slice(&0u32.to_le_bytes()); // n_vectors = 0 + let p = forge("dim_one.tvr", &v); + let r = std::panic::catch_unwind(|| Rank::load(&p)); + std::fs::remove_file(&p).ok(); + assert!(r.is_ok(), "Rank::load panicked on dim = 1"); + assert!(r.unwrap().is_err(), "Rank::load must reject dim = 1"); +} + +/// DELTA-A4: a `dim == 0` header must be rejected by *all four* loaders. +/// `dim = 0` satisfies `dim % 64 == 0` (a trap for the bitmap formats) +/// and would yield `qwords_per_vec == 0` / a div-by-zero downstream, so +/// the loaders must catch it at the header. Payload is empty to isolate +/// the dim gate. +#[test] +fn delta_a4_all_loaders_reject_dim_zero() { + // TVR1 + { + let mut v = Vec::new(); + v.extend_from_slice(b"TVR1"); + v.push(1); + v.extend_from_slice(&0u32.to_le_bytes()); // dim + v.extend_from_slice(&0u32.to_le_bytes()); // n_vectors + let p = forge("dim0.tvr", &v); + let r = std::panic::catch_unwind(|| Rank::load(&p)); + std::fs::remove_file(&p).ok(); + assert!(r.is_ok() && r.unwrap().is_err(), "TVR1 dim=0 must Err"); + } + // TVRQ (extra `bits` byte) + { + let mut v = Vec::new(); + v.extend_from_slice(b"TVRQ"); + v.push(1); + v.push(2); // bits = 2 + v.extend_from_slice(&0u32.to_le_bytes()); // dim + v.extend_from_slice(&0u32.to_le_bytes()); // n_vectors + let p = forge("dim0.tvrq", &v); + let r = std::panic::catch_unwind(|| RankQuant::load(&p)); + std::fs::remove_file(&p).ok(); + assert!(r.is_ok() && r.unwrap().is_err(), "TVRQ dim=0 must Err"); + } + // TVBM (extra `n_top` field) + { + let mut v = Vec::new(); + v.extend_from_slice(b"TVBM"); + v.push(1); + v.extend_from_slice(&0u32.to_le_bytes()); // dim + v.extend_from_slice(&0u32.to_le_bytes()); // n_top + v.extend_from_slice(&0u32.to_le_bytes()); // n_vectors + let p = forge("dim0.tvbm", &v); + let r = std::panic::catch_unwind(|| Bitmap::load(&p)); + std::fs::remove_file(&p).ok(); + assert!(r.is_ok() && r.unwrap().is_err(), "TVBM dim=0 must Err"); + } + // TVSB + { + let mut v = Vec::new(); + v.extend_from_slice(b"TVSB"); + v.push(1); + v.extend_from_slice(&0u32.to_le_bytes()); // dim + v.extend_from_slice(&0u32.to_le_bytes()); // n_vectors + let p = forge("dim0.tvsb", &v); + let r = std::panic::catch_unwind(|| SignBitmap::load(&p)); + std::fs::remove_file(&p).ok(); + assert!(r.is_ok() && r.unwrap().is_err(), "TVSB dim=0 must Err"); + } +} + +/// DELTA-A5: TVBM `dim` that is a multiple of 64 but exceeds `MAX_DIM` +/// (`u16::MAX`). `Bitmap` carries the same `u16` rank-storage invariant as +/// `Rank`, so `check_dim` caps it at `MAX_DIM`; `65536` is the smallest +/// multiple of 64 above the cap and must be rejected (otherwise a loaded +/// index would panic on the first query's `dim as u16` truncation). +#[test] +fn delta_a5_tvbm_rejects_dim_over_max_dim() { + let dim = MAX_DIM_U32 + 1; // 65536, a multiple of 64 + assert_eq!(dim % 64, 0, "test fixture: 65536 must be a multiple of 64"); + let mut v = Vec::new(); + v.extend_from_slice(b"TVBM"); + v.push(1); + v.extend_from_slice(&dim.to_le_bytes()); + v.extend_from_slice(&100u32.to_le_bytes()); // n_top + v.extend_from_slice(&0u32.to_le_bytes()); // n_vectors + let p = forge("bm_dim_over_max.tvbm", &v); + let r = std::panic::catch_unwind(|| Bitmap::load(&p)); + std::fs::remove_file(&p).ok(); + assert!(r.is_ok(), "Bitmap::load panicked on dim > MAX_DIM"); + assert!( + r.unwrap().is_err(), + "Bitmap::load must reject dim > MAX_DIM even when dim % 64 == 0" + ); +} + +/// DELTA-A6: TVSB `dim` above `MAX_SIGN_BITMAP_DIM` (`1 << 24`). Sign +/// bitmaps do *not* share the `u16` rank cap (they round-trip dims well +/// above `u16::MAX`, pinned by `sign_bitmap`'s own `large_dim` test), so +/// their ceiling is the higher `MAX_SIGN_BITMAP_DIM`. Just over it, with a +/// 64-multiple dim, must still be rejected. +#[test] +fn delta_a6_tvsb_rejects_dim_over_max_sign_bitmap_dim() { + let dim = MAX_SIGN_BITMAP_DIM_U32 + 64; // 16_777_280, a multiple of 64 + assert_eq!(dim % 64, 0, "test fixture: dim must be a multiple of 64"); + let mut v = Vec::new(); + v.extend_from_slice(b"TVSB"); + v.push(1); + v.extend_from_slice(&dim.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); // n_vectors + let p = forge("sb_dim_over_max.tvsb", &v); + let r = std::panic::catch_unwind(|| SignBitmap::load(&p)); + std::fs::remove_file(&p).ok(); + assert!( + r.is_ok(), + "SignBitmap::load panicked on dim > MAX_SIGN_BITMAP_DIM" + ); + assert!( + r.unwrap().is_err(), + "SignBitmap::load must reject dim > MAX_SIGN_BITMAP_DIM" + ); +} + +/// DELTA-A7: version bytes other than `1`. `0`, `2`, and `255` must each +/// be rejected by every loader's `if ver[0] != VERSION` check (a forged or +/// future-format file must not be silently parsed under the v1 layout). +#[test] +fn delta_a7_all_loaders_reject_bad_version_bytes() { + for ver in [0u8, 2u8, 255u8] { + let mut v = Vec::new(); + v.extend_from_slice(b"TVR1"); + v.push(ver); + v.extend_from_slice(&1024u32.to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + let p = forge(&format!("ver_{ver}.tvr"), &v); + let r = std::panic::catch_unwind(|| Rank::load(&p)); + std::fs::remove_file(&p).ok(); + assert!(r.is_ok(), "Rank::load panicked on version {ver}"); + assert!( + r.unwrap().is_err(), + "Rank::load must reject version byte {ver}" + ); + } +} + +/// DELTA-A8: all-`0xFF` files at each format's header length and a few +/// larger sizes. `0xFF` magic fails the magic check; `0xFF` version fails +/// the version check; a larger all-`0xFF` file decodes to an absurd +/// `dim`/`n_vectors` that trips the size guards. Every loader must `Err` +/// without panicking on every length. +#[test] +fn delta_a8_all_loaders_reject_all_ff_files() { + for len in [13usize, 14, 17, 32, 64, 256, 4096] { + let bytes = vec![0xFFu8; len]; + let p = forge(&format!("all_ff_{len}.bin"), &bytes); + assert_all_loaders_reject(&p, &format!("all-0xFF len={len}")); + std::fs::remove_file(&p).ok(); + } +} + +// ===================================================================== +// DELTA-B — integer overflow at the dimension ceiling (64-bit). +// ===================================================================== + +/// DELTA-B1: symmetric `Rank::search` at the true `dim = u16::MAX` +/// ceiling. The inner loop accumulates +/// `acc: i64 += (2·q − (D−1)) · (2·doc − (D−1))` over `dim` coordinates. +/// At `dim = 65535` the per-term magnitude peaks near `65535² ≈ 4.3e9` +/// (already past `i32::MAX`, hence the deliberate `as i64` widening) and +/// the sum over `dim` terms peaks near `2.8e14` — comfortably inside +/// `i64` but worth pinning at the exact boundary so a future refactor +/// that narrows the accumulator (or `mean_2x`'s `i32`) is caught. Also +/// exercises `rank_norm(65535)`, which forms its product in `f64` to +/// avoid `f32` overflow. Both score paths must stay finite. +#[test] +fn delta_b1_rank_symmetric_no_overflow_at_max_dim() { + let dim = u16::MAX as usize; // 65535 — the largest constructible dim + let n = 3; + let corpus = make_corpus(8001, n, dim); + let mut idx = Rank::new(dim); + idx.add(&corpus); + let query = make_corpus(8002, 1, dim); + + // rank_norm must not overflow to a non-finite value at the ceiling. + let norm = rank_norm(dim); + assert!( + norm.is_finite() && norm > 0.0, + "rank_norm({dim}) = {norm} must be finite and positive" + ); + + let res = idx.search(&query, n); + assert_eq!(res.k, n); + for &s in res.scores_for_query(0) { + assert!( + s.is_finite(), + "symmetric score at dim={dim} must be finite (no i64/i32 wrap, no norm overflow); got {s}" + ); + } + + // Asymmetric path shares the same dim ceiling and the f32 dot/accumulate. + let resa = idx.search_asymmetric(&query, n); + for &s in resa.scores_for_query(0) { + assert!( + s.is_finite(), + "asymmetric score at dim={dim} must be finite; got {s}" + ); + } +} + +// ===================================================================== +// DELTA-C — search_asymmetric_subset: candidate-list edge cases. +// ===================================================================== + +/// DELTA-C1: an empty candidate list returns empty `(scores, indices)` +/// (the `m == 0 → k_eff == 0` early-out), and `k == 0` with a non-empty +/// list does the same. Neither path may panic on the zero-length scratch +/// buffer or the `vec![0u8; m * bpv]` gather. +#[test] +fn delta_c1_subset_empty_list_and_zero_k() { + let dim = 64; + let n = 32; + let corpus = make_corpus(8101, n, dim); + let mut idx = RankQuant::new(dim, 2); + idx.add(&corpus); + let query = make_corpus(8102, 1, dim); + + let (s0, g0) = idx.search_asymmetric_subset(&query, &[], 5); + assert!( + s0.is_empty() && g0.is_empty(), + "empty candidate list must return empty" + ); + + let (s1, g1) = idx.search_asymmetric_subset(&query, &[0, 1, 2], 0); + assert!(s1.is_empty() && g1.is_empty(), "k == 0 must return empty"); +} + +/// DELTA-C2 (contract pin): duplicate candidate ids are **accepted**, and +/// the same global doc is returned once per occurrence. This is the +/// documented gather behaviour — the subset API scores each candidate +/// position independently (`sub_packed[i*bpv..]` is a copy per slot) and +/// `TopK` keeps distinct *local* positions that map back to the same +/// global id. The API deliberately does not deduplicate; a caller that +/// passes a deduped list gets deduped results. Pinned so the behaviour is +/// a conscious contract, not an accident: feeding `[7, 7, 7]` with `k = 3` +/// returns three identical scores all mapping to global id 7. +#[test] +fn delta_c2_subset_duplicate_ids_returned_per_occurrence() { + let dim = 64; + let n = 32; + let corpus = make_corpus(8201, n, dim); + let mut idx = RankQuant::new(dim, 2); + idx.add(&corpus); + let query = make_corpus(8202, 1, dim); + + let cands: Vec = vec![7, 7, 7]; + let (scores, global) = idx.search_asymmetric_subset(&query, &cands, 3); + assert_eq!(scores.len(), 3); + assert_eq!(global.len(), 3); + // Every returned id is the duplicated candidate. + assert!( + global.iter().all(|&g| g == 7), + "duplicate candidate list [7,7,7] must map every result to global id 7; got {global:?}", + ); + // The repeated doc yields the identical score each time (same bytes + // gathered, same kernel) and is finite. + let s0 = scores[0]; + assert!(s0.is_finite(), "duplicate-candidate score must be finite"); + for &s in &scores { + assert_eq!( + s, s0, + "all occurrences of one duplicated doc must score identically" + ); + } + + // Cross-check against the single-occurrence score: the per-doc value + // is independent of how many times the id appears. + let (single, _) = idx.search_asymmetric_subset(&query, &[7], 1); + assert!( + (single[0] - s0).abs() < 1e-6, + "duplicate-occurrence score {s0} must equal the single-occurrence score {}", + single[0], + ); +} + +/// DELTA-C3: `k` larger than the candidate count clamps to `m` +/// (`k_eff = k.min(m)`) — even with a duplicate in the list. `k = 10` +/// over a 3-element list `[3, 3, 9]` must return exactly 3 results, not +/// pad to 10 or over-read. +#[test] +fn delta_c3_subset_k_greater_than_m_clamps() { + let dim = 64; + let n = 32; + let corpus = make_corpus(8301, n, dim); + let mut idx = RankQuant::new(dim, 2); + idx.add(&corpus); + let query = make_corpus(8302, 1, dim); + + let cands: Vec = vec![3, 3, 9]; + let (scores, global) = idx.search_asymmetric_subset(&query, &cands, 10); + assert_eq!(scores.len(), 3, "k must clamp to candidate count m"); + assert_eq!(global.len(), 3); + // All three slots fill (k clamps to the 3 in-range candidates), so every + // returned id is a non-sentinel id drawn from the candidate set. Assert the + // two properties separately so the i64 sentinel check and the u32 membership + // check stay type-consistent. + for &g in &global { + assert!(g >= 0, "unexpected sentinel id {g}"); + assert!( + cands.contains(&(g as u32)), + "result id {g} not in candidate set {cands:?}" + ); + } +} + +/// DELTA-C4: a duplicated id mixed with an out-of-range id +/// (`[5, 999, 5]`, `n_vectors == 32`) must still trip the bounds assert — +/// the out-of-range guard scans the *whole* list, so a dup neither masks +/// nor bypasses it. Pins the `alpha` "subset rejects out-of-range" guard +/// against a duplicate-laden adversarial list. +#[test] +#[should_panic(expected = "candidate id out of range")] +fn delta_c4_subset_dup_plus_oob_still_rejected() { + let dim = 64; + let n = 32; + let corpus = make_corpus(8401, n, dim); + let mut idx = RankQuant::new(dim, 2); + idx.add(&corpus); + let query = make_corpus(8402, 1, dim); + // 999 >= n_vectors (32): must panic before the gather, dup or not. + let _ = idx.search_asymmetric_subset(&query, &[5, 999, 5], 3); +} + +// ===================================================================== +// DELTA-D — empty-index / empty-input search paths. +// ===================================================================== + +/// DELTA-D1: searching an index that has had no `add` (and a degenerate +/// `add(&[])`) must clamp `k` to `0` and return a correctly-shaped empty +/// result across all four types, never panic on `par_chunks_mut(0)` or an +/// empty scan. +#[test] +fn delta_d1_search_before_any_add() { + let dim = 128; + let query = make_corpus(8501, 1, dim); + + // Rank + let idx = Rank::new(dim); + let r = idx.search(&query, 5); + assert_eq!(r.k, 0); + assert!(r.scores.is_empty() && r.indices.is_empty()); + + // RankQuant (both symmetric and asymmetric) + let idx = RankQuant::new(dim, 2); + assert_eq!(idx.search(&query, 5).k, 0); + assert_eq!(idx.search_asymmetric(&query, 5).k, 0); + + // Bitmap + let idx = Bitmap::new(dim, 32); + assert_eq!(idx.search(&query, 5).k, 0); + assert!(idx.top_m_candidates(&query, 5).is_empty()); + + // SignBitmap — single and batched candidate paths. + let idx = SignBitmap::new(dim); + assert!(idx.top_m_candidates(&query, 5).is_empty()); + let batched = idx.top_m_candidates_batched(&make_corpus(8502, 3, dim), 5); + assert_eq!(batched.len(), 3); + assert!(batched.iter().all(|v| v.is_empty())); + + // Degenerate add(&[]) is a no-op (n == 0) and leaves the index empty. + let mut idx = Rank::new(dim); + idx.add(&[]); + assert_eq!(idx.len(), 0); +} + +/// DELTA-D2: `body_overlap_scores_subset` with an empty `doc_ids` slice +/// is a no-op (empty `out`), even though the AVX-512 dispatch is reached +/// (the `n == 0` loop body never runs). And an in-range-looking id `0` +/// against an *empty* index must fail the bounds assert (`0 < 0` is +/// false) — the guard does not special-case the empty corpus. +#[test] +fn delta_d2_body_overlap_empty_doc_ids_and_empty_index() { + let dim = 128; + let n_top = 32; + + // Empty doc_ids on a populated index: no-op, no panic. + let mut idx = Bitmap::new(dim, n_top); + idx.add(&make_corpus(8601, 8, dim)); + let q = make_corpus(8602, 1, dim); + let qb = idx.build_query_bitmap_fp32(&q); + let mut out: Vec = Vec::new(); + idx.body_overlap_scores_subset(&qb, &[], &mut out); + assert!(out.is_empty(), "empty doc_ids must leave out empty"); + + // Id 0 against an empty index must be rejected by the bounds assert. + let empty = Bitmap::new(dim, n_top); + let qb_empty = empty.build_query_bitmap_fp32(&q); + let mut out1 = vec![0u32; 1]; + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + empty.body_overlap_scores_subset(&qb_empty, &[0], &mut out1) + })); + assert!( + r.is_err(), + "body_overlap_scores_subset must reject id 0 against an empty index" + ); +} + +/// DELTA-D3: `swap_remove` down to empty, then search; and `swap_remove` +/// of a middle element followed by re-`add` (buffer-integrity check). The +/// remove must keep the packed buffer consistent so a later search over +/// the surviving + newly-added docs returns exactly the live count. +#[test] +fn delta_d3_swap_remove_to_empty_then_readd() { + let dim = 64; + + // Remove the only element → empty, then search returns nothing. + let mut idx = Rank::new(dim); + idx.add(&make_corpus(8701, 1, dim)); + let moved = idx.swap_remove(0); + assert_eq!(moved, 0, "removing the sole element reports last index 0"); + assert!(idx.is_empty()); + let q = make_corpus(8702, 1, dim); + assert_eq!(idx.search(&q, 5).k, 0, "search over emptied index is empty"); + + // Remove a middle element, re-add, and verify the live count searches + // cleanly (no stale bytes, no over/under count). + let mut idx = Rank::new(dim); + idx.add(&make_corpus(8703, 4, dim)); // 4 docs, ids 0..=3 + let last_moved = idx.swap_remove(1); // pulls id 3 into slot 1 + assert_eq!(last_moved, 3); + assert_eq!(idx.len(), 3); + idx.add(&make_corpus(8704, 2, dim)); // back to 5 + assert_eq!(idx.len(), 5); + let res = idx.search(&q, 100); // k clamps to 5 + let valid = res.indices_for_query(0).iter().filter(|&&i| i >= 0).count(); + assert_eq!( + valid, 5, + "all 5 live docs must be returned after remove+readd" + ); +} + +/// DELTA-D4: a large query count `nq` with a small `k` exercises the +/// `result_buffer_len(nq, k) = nq * k` axis (the half of the +/// capacity-overflow guard the `beta` suite covers from the `k` side). +/// `5000 * 2 = 10000` slots must allocate and fill cleanly, with every +/// per-query block correctly sliceable. +#[test] +fn delta_d4_large_nq_small_k() { + let dim = 64; + let n = 8; + let mut idx = Rank::new(dim); + idx.add(&make_corpus(8801, n, dim)); + let nq = 5000; + let queries = make_corpus(8802, nq, dim); + + let res = idx.search(&queries, 2); + assert_eq!(res.nq, nq); + assert_eq!(res.k, 2); + assert_eq!(res.scores.len(), nq * 2); + assert_eq!(res.indices.len(), nq * 2); + // Spot-check a couple of per-query blocks: each returns min(k, n) live ids. + for qi in [0usize, nq / 2, nq - 1] { + let valid = res + .indices_for_query(qi) + .iter() + .filter(|&&i| i >= 0) + .count(); + assert_eq!( + valid, 2, + "query {qi} must return k=2 live results (n=8 >= 2)" + ); + } +} + +// ===================================================================== +// DELTA-E — documented fail-loud contracts. +// ===================================================================== + +/// DELTA-E1: the bench-only `search_asymmetric_byte_lut` is documented to +/// support only `bits ∈ {2, 4}` and to panic on a `b = 1` index. Pin the +/// panic so the documented restriction cannot silently regress into a +/// wrong-result path (the production `RankQuant::search_asymmetric` routes +/// `b = 1` to the scalar LUT and is unaffected — covered by the `beta` +/// suite). This is an intentional, documented contract, not a bug. +#[test] +#[should_panic(expected = "byte-LUT path only supports bits")] +fn delta_e1_byte_lut_panics_on_b1_index() { + let dim = 64; + let mut idx = RankQuant::new(dim, 1); + idx.add(&make_corpus(8901, 8, dim)); + let query = make_corpus(8902, 1, dim); + let _ = search_asymmetric_byte_lut(&idx, &query, 3); +}