feat(scenegraph): add hash codec for bits/tensor/bytes/cam_row conversion

Introduce hash_codec module providing bidirectional encoding/decoding:
- bits_tensor_to_hash_bytes / hash_bytes_to_bits_array
- bits_tensor_to_cam_row
- hash_bytes_to_cam_row / cam_row_to_hash_bytes
This commit is contained in:
2026-05-17 19:41:03 +08:00
parent 4ea567adba
commit ddb8cff6a9
3 changed files with 261 additions and 1 deletions

182
tests/test_hash_codec.py Normal file
View File

@@ -0,0 +1,182 @@
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
import torch
MINI_NAV_DIR = Path(__file__).resolve().parents[1] / "mini-nav"
sys.path.insert(0, str(MINI_NAV_DIR))
from scenegraph.hash_codec import ( # noqa: E402
bits_tensor_to_cam_row,
bits_tensor_to_hash_bytes,
cam_row_to_hash_bytes,
hash_bytes_to_bits_array,
hash_bytes_to_cam_row,
)
WIDTH = 512
def _xnor_score(query_row: int, stored_row: int, *, width: int = WIDTH) -> int:
mask = (1 << width) - 1
return int((~(query_row ^ stored_row) & mask).bit_count())
def _hamming_distance(left: np.ndarray, right: np.ndarray) -> int:
return int((left != right).sum())
def test_all_zero_hash_roundtrips_through_bytes_and_cam_row():
bits = torch.zeros(WIDTH, dtype=torch.int32)
hash_bytes = bits_tensor_to_hash_bytes(bits)
cam_row = hash_bytes_to_cam_row(hash_bytes)
roundtrip = cam_row_to_hash_bytes(cam_row)
assert len(hash_bytes) == WIDTH // 8
assert hash_bytes == bytes(WIDTH // 8)
assert cam_row == 0
assert roundtrip == hash_bytes
def test_all_one_hash_roundtrips_through_bytes_and_cam_row():
bits = torch.ones(WIDTH, dtype=torch.int32)
hash_bytes = bits_tensor_to_hash_bytes(bits)
cam_row = hash_bytes_to_cam_row(hash_bytes)
roundtrip = cam_row_to_hash_bytes(cam_row)
assert hash_bytes == b"\xff" * (WIDTH // 8)
assert cam_row == (1 << WIDTH) - 1
assert roundtrip == hash_bytes
def test_first_bit_uses_packbits_high_bit_ordering():
bits = torch.zeros(WIDTH, dtype=torch.int32)
bits[0] = 1
hash_bytes = bits_tensor_to_hash_bytes(bits)
cam_row = hash_bytes_to_cam_row(hash_bytes)
assert hash_bytes[0] == 0b10000000
assert cam_row == 1 << (WIDTH - 1)
def test_last_bit_maps_to_least_significant_cam_row_bit():
bits = torch.zeros(WIDTH, dtype=torch.int32)
bits[WIDTH - 1] = 1
hash_bytes = bits_tensor_to_hash_bytes(bits)
cam_row = hash_bytes_to_cam_row(hash_bytes)
assert hash_bytes[-1] == 0b00000001
assert cam_row == 1
def test_hash_bytes_unpack_to_bits_array_with_packbits_ordering():
hash_bytes = bytes([0b10100000]) + bytes((WIDTH // 8) - 1)
bits = hash_bytes_to_bits_array(hash_bytes)
assert bits.shape == (WIDTH,)
assert bits.dtype == np.uint8
assert bits[:4].tolist() == [1, 0, 1, 0]
assert bits[4:].sum() == 0
def test_bits_tensor_to_cam_row_matches_bytes_conversion():
bits = torch.zeros(WIDTH, dtype=torch.int32)
bits[0] = 1
bits[7] = 1
bits[511] = 1
hash_bytes = bits_tensor_to_hash_bytes(bits)
assert bits_tensor_to_cam_row(bits) == hash_bytes_to_cam_row(hash_bytes)
def test_positive_threshold_accepts_bool_and_signed_hash_encodings():
bool_bits = torch.zeros(WIDTH, dtype=torch.bool)
bool_bits[0] = True
signed_bits = torch.full((WIDTH,), -1, dtype=torch.int32)
signed_bits[0] = 1
assert bits_tensor_to_hash_bytes(bool_bits) == bits_tensor_to_hash_bytes(signed_bits)
def test_xnor_score_matches_width_minus_hamming_distance():
zeros = torch.zeros(WIDTH, dtype=torch.int32)
ones = torch.ones(WIDTH, dtype=torch.int32)
one_bit_diff = torch.zeros(WIDTH, dtype=torch.int32)
one_bit_diff[0] = 1
zero_bytes = bits_tensor_to_hash_bytes(zeros)
one_bytes = bits_tensor_to_hash_bytes(ones)
one_diff_bytes = bits_tensor_to_hash_bytes(one_bit_diff)
zero_row = hash_bytes_to_cam_row(zero_bytes)
one_row = hash_bytes_to_cam_row(one_bytes)
one_diff_row = hash_bytes_to_cam_row(one_diff_bytes)
zero_bits = hash_bytes_to_bits_array(zero_bytes)
one_bits = hash_bytes_to_bits_array(one_bytes)
one_diff_bits = hash_bytes_to_bits_array(one_diff_bytes)
assert _hamming_distance(zero_bits, zero_bits) == 0
assert _xnor_score(zero_row, zero_row) == WIDTH
assert _hamming_distance(zero_bits, one_diff_bits) == 1
assert _xnor_score(zero_row, one_diff_row) == WIDTH - 1
assert _hamming_distance(zero_bits, one_bits) == WIDTH
assert _xnor_score(zero_row, one_row) == 0
@pytest.mark.parametrize(
"bits",
[
torch.zeros(WIDTH - 1, dtype=torch.int32),
torch.zeros(WIDTH + 1, dtype=torch.int32),
],
)
def test_bits_tensor_to_hash_bytes_rejects_wrong_width(bits: torch.Tensor):
with pytest.raises(ValueError, match="exactly 512 values"):
bits_tensor_to_hash_bytes(bits)
def test_hash_bytes_to_bits_array_rejects_wrong_byte_length():
with pytest.raises(ValueError, match="exactly 64 bytes"):
hash_bytes_to_bits_array(bytes(63))
@pytest.mark.parametrize("cam_row", [-1, 1 << WIDTH])
def test_cam_row_to_hash_bytes_rejects_out_of_range_rows(cam_row: int):
with pytest.raises(ValueError, match="range"):
cam_row_to_hash_bytes(cam_row)
def test_width_must_be_divisible_by_eight():
with pytest.raises(ValueError, match="divisible by 8"):
bits_tensor_to_hash_bytes(torch.zeros(7, dtype=torch.int32), width=7)
def test_scenegraph_package_exports_hash_codec_helpers():
from scenegraph import ( # noqa: PLC0415
bits_tensor_to_cam_row as exported_bits_tensor_to_cam_row,
bits_tensor_to_hash_bytes as exported_bits_tensor_to_hash_bytes,
cam_row_to_hash_bytes as exported_cam_row_to_hash_bytes,
hash_bytes_to_bits_array as exported_hash_bytes_to_bits_array,
hash_bytes_to_cam_row as exported_hash_bytes_to_cam_row,
)
assert exported_bits_tensor_to_cam_row is bits_tensor_to_cam_row
assert exported_bits_tensor_to_hash_bytes is bits_tensor_to_hash_bytes
assert exported_cam_row_to_hash_bytes is cam_row_to_hash_bytes
assert exported_hash_bytes_to_bits_array is hash_bytes_to_bits_array
assert exported_hash_bytes_to_cam_row is hash_bytes_to_cam_row