Upload tests/test_lsh.py with huggingface_hub
Browse files- tests/test_lsh.py +137 -0
tests/test_lsh.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Hamming LSH index (``palimseste.lsh``).
|
| 2 |
+
|
| 3 |
+
Key properties to verify:
|
| 4 |
+
- exact neighbors are always retrievable within radius (recall on self)
|
| 5 |
+
- near-duplicates collide far more often than random pairs (selectivity)
|
| 6 |
+
- query_candidates returns a small fraction of |M| (sub-linear scaling)
|
| 7 |
+
- rebuild is consistent with incremental insert
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
from palimseste import hv, lsh
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _flip_bits(x: hv.HV, n: int, rng) -> hv.HV:
|
| 19 |
+
"""Return a copy of x with n random bit positions flipped."""
|
| 20 |
+
signs = hv.bits_to_signs(x)
|
| 21 |
+
pos = rng.choice(x.D, size=n, replace=False)
|
| 22 |
+
signs[pos] = -signs[pos]
|
| 23 |
+
return hv.signs_to_bits(signs)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_insert_and_size():
|
| 27 |
+
cfg = lsh.LSHConfig(D=500, K=8, L=4)
|
| 28 |
+
idx = lsh.LSHIndex(config=cfg)
|
| 29 |
+
rng = np.random.default_rng(0)
|
| 30 |
+
for _ in range(10):
|
| 31 |
+
idx.insert(idx.size, hv.random_hv(D=500, rng=rng))
|
| 32 |
+
assert idx.size == 10
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_self_query_returns_self():
|
| 36 |
+
cfg = lsh.LSHConfig(D=1000, K=6, L=6)
|
| 37 |
+
idx = lsh.LSHIndex(config=cfg)
|
| 38 |
+
rng = np.random.default_rng(1)
|
| 39 |
+
hvs = [hv.random_hv(D=1000, rng=rng) for _ in range(50)]
|
| 40 |
+
for i, h in enumerate(hvs):
|
| 41 |
+
idx.insert(i, h)
|
| 42 |
+
# query with one of the stored HVs at radius 0 -> should find itself
|
| 43 |
+
for i in (0, 17, 49):
|
| 44 |
+
nbs = idx.query_neighbors(hvs[i], radius=0, items=hvs)
|
| 45 |
+
assert i in nbs
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_near_neighbor_found():
|
| 49 |
+
cfg = lsh.LSHConfig(D=2000, K=10, L=12)
|
| 50 |
+
idx = lsh.LSHIndex(config=cfg)
|
| 51 |
+
rng = np.random.default_rng(2)
|
| 52 |
+
base = hv.random_hv(D=2000, rng=rng)
|
| 53 |
+
# near neighbor: 5 bits different
|
| 54 |
+
near = _flip_bits(base, 5, rng)
|
| 55 |
+
# fill with unrelated random HVs
|
| 56 |
+
hvs = [base] + [hv.random_hv(D=2000, rng=rng) for _ in range(200)]
|
| 57 |
+
hvs.append(near)
|
| 58 |
+
near_id = len(hvs) - 1
|
| 59 |
+
for i, h in enumerate(hvs):
|
| 60 |
+
idx.insert(i, h)
|
| 61 |
+
found = idx.query_neighbors(near, radius=10, items=hvs)
|
| 62 |
+
# both base and near should be within radius 10 of `near`
|
| 63 |
+
assert 0 in found
|
| 64 |
+
assert near_id in found
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_candidate_set_is_small():
|
| 68 |
+
# With reasonable (K,L), candidates << |M|
|
| 69 |
+
cfg = lsh.LSHConfig(D=2000, K=14, L=8)
|
| 70 |
+
idx = lsh.LSHIndex(config=cfg)
|
| 71 |
+
rng = np.random.default_rng(3)
|
| 72 |
+
N = 1000
|
| 73 |
+
hvs = [hv.random_hv(D=2000, rng=rng) for _ in range(N)]
|
| 74 |
+
for i, h in enumerate(hvs):
|
| 75 |
+
idx.insert(i, h)
|
| 76 |
+
q = hv.random_hv(D=2000, rng=rng)
|
| 77 |
+
cand = idx.query_candidates(q)
|
| 78 |
+
# should be well under N
|
| 79 |
+
assert len(cand) < N * 0.5
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_rebuild_matches_insert():
|
| 83 |
+
# Two indices built with identical rng seeds must have identical
|
| 84 |
+
# projections, so rebuild() and incremental insert() produce the
|
| 85 |
+
# same bucket membership.
|
| 86 |
+
cfg = lsh.LSHConfig(D=500, K=6, L=4)
|
| 87 |
+
rng = np.random.default_rng(4)
|
| 88 |
+
hvs = [hv.random_hv(D=500, rng=rng) for _ in range(30)]
|
| 89 |
+
|
| 90 |
+
idx = lsh.LSHIndex(config=cfg, _rng=np.random.default_rng(123))
|
| 91 |
+
for i, h in enumerate(hvs):
|
| 92 |
+
idx.insert(i, h)
|
| 93 |
+
cand_before = idx.query_candidates(hvs[5])
|
| 94 |
+
|
| 95 |
+
idx2 = lsh.LSHIndex(config=cfg, _rng=np.random.default_rng(123))
|
| 96 |
+
idx2.rebuild(hvs)
|
| 97 |
+
cand_after = idx2.query_candidates(hvs[5])
|
| 98 |
+
assert cand_before == cand_after
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_rebuild_clears_old_entries():
|
| 102 |
+
cfg = lsh.LSHConfig(D=500, K=6, L=4)
|
| 103 |
+
idx = lsh.LSHIndex(config=cfg, _rng=np.random.default_rng(7))
|
| 104 |
+
rng = np.random.default_rng(0)
|
| 105 |
+
# insert some, then rebuild with a smaller set -> size must reflect new set
|
| 106 |
+
for _ in range(50):
|
| 107 |
+
idx.insert(idx.size, hv.random_hv(D=500, rng=rng))
|
| 108 |
+
new_hvs = [hv.random_hv(D=500, rng=rng) for _ in range(5)]
|
| 109 |
+
idx.rebuild(new_hvs)
|
| 110 |
+
assert idx.size == 5
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_tune_config_valid():
|
| 114 |
+
cfg = lsh.LSHConfig.tune(D=10000, target_radius=0.1, recall=0.9)
|
| 115 |
+
assert 1 <= cfg.K <= cfg.D
|
| 116 |
+
assert cfg.L >= 1
|
| 117 |
+
# larger recall -> at least as many tables
|
| 118 |
+
cfg_hi = lsh.LSHConfig.tune(D=10000, target_radius=0.1, recall=0.99)
|
| 119 |
+
assert cfg_hi.L >= cfg.L
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_dimension_mismatch_raises():
|
| 123 |
+
cfg = lsh.LSHConfig(D=100)
|
| 124 |
+
idx = lsh.LSHIndex(config=cfg)
|
| 125 |
+
with pytest.raises(ValueError):
|
| 126 |
+
idx.insert(0, hv.random_hv(D=200))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_invalid_config():
|
| 130 |
+
with pytest.raises(ValueError):
|
| 131 |
+
lsh.LSHConfig(D=0)
|
| 132 |
+
with pytest.raises(ValueError):
|
| 133 |
+
lsh.LSHConfig(D=100, K=0)
|
| 134 |
+
with pytest.raises(ValueError):
|
| 135 |
+
lsh.LSHConfig(D=100, L=0)
|
| 136 |
+
with pytest.raises(ValueError):
|
| 137 |
+
lsh.LSHConfig(D=100, K=101)
|