Skip to content

Commit ee0e500

Browse files
committed
Implement kh57 algorithm and tests
1 parent afa4137 commit ee0e500

11 files changed

Lines changed: 840 additions & 8 deletions

File tree

src/kh57/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
"""kh57 - efficient range reservoir sampling from massive sorted KV datasets."""
2+
3+
from kh57.backends import Backend, MemBackend
4+
from kh57.encoding import kh57, recover, uniform_hash
5+
from kh57.sampling import sample
6+
7+
8+
__all__ = ["Backend", "MemBackend", "kh57", "recover", "sample", "uniform_hash"]
9+
__version__ = "0.1.0"

src/kh57/backends/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
"""kh57 backends - storage adapters for sampling sources."""
2+
3+
from kh57.backends.mem import Backend, MemBackend
4+
5+
6+
__all__ = ["Backend", "MemBackend"]

src/kh57/backends/mem.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,87 @@
1-
"""In-memory sorted-dict backend for kh57.
1+
"""In-memory backend and the Backend protocol.
22
3-
Stub. Implementation coming later.
3+
the Backend protocol is the minimum sorted-KV surface `kh57.sample()` needs.
4+
keys are raw bytes (callers big-endian encode their ints), so the protocol
5+
stays agnostic of key semantics and disk-backed adapters (rocksdb, lmdb)
6+
just need to match the same shape.
47
"""
8+
9+
from __future__ import annotations
10+
11+
from bisect import bisect_left
12+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
13+
14+
15+
if TYPE_CHECKING:
16+
from collections.abc import Iterator
17+
18+
19+
__all__ = ["Backend", "MemBackend"]
20+
21+
22+
@runtime_checkable
23+
class Backend(Protocol):
24+
"""Minimum sorted-KV interface required by `kh57.sample()`."""
25+
26+
def get(self, key: bytes) -> bytes | None:
27+
"""Return the value for `key`, or None if absent."""
28+
...
29+
30+
def put(self, key: bytes, value: bytes) -> None:
31+
"""Insert or overwrite `key` with `value`."""
32+
...
33+
34+
def delete(self, key: bytes) -> None:
35+
"""Remove `key` if present."""
36+
...
37+
38+
def range_scan(self, lo: bytes, hi: bytes) -> Iterator[tuple[bytes, bytes]]:
39+
"""Yield (key, value) pairs with lo <= key < hi, ascending by key."""
40+
...
41+
42+
43+
class MemBackend:
44+
"""Reference in-memory backend: a dict plus a lazily sorted key list.
45+
46+
a plain dict holds the data; the sorted key list is rebuilt on the first
47+
`range_scan` after a mutation. this avoids a third-party sorted-container
48+
dependency and is fast for the dominant pattern (bulk load, then scan):
49+
writes are O(1), and sorting is paid once per write burst instead of per
50+
write. not safe to mutate while a `range_scan` iterator is live.
51+
"""
52+
53+
def __init__(self) -> None:
54+
"""Create an empty backend."""
55+
self._data: dict[bytes, bytes] = {}
56+
self._sorted_keys: list[bytes] = []
57+
self._dirty = False
58+
59+
def get(self, key: bytes) -> bytes | None:
60+
"""Return the value for `key`, or None if absent."""
61+
return self._data.get(key)
62+
63+
def put(self, key: bytes, value: bytes) -> None:
64+
"""Insert or overwrite `key` with `value`."""
65+
if key not in self._data:
66+
self._dirty = True
67+
self._data[key] = value
68+
69+
def delete(self, key: bytes) -> None:
70+
"""Remove `key` if present."""
71+
if self._data.pop(key, None) is not None and not self._dirty:
72+
# cheap incremental removal keeps the sorted list valid
73+
idx = bisect_left(self._sorted_keys, key)
74+
del self._sorted_keys[idx]
75+
76+
def range_scan(self, lo: bytes, hi: bytes) -> Iterator[tuple[bytes, bytes]]:
77+
"""Yield (key, value) pairs with lo <= key < hi, ascending by key."""
78+
if self._dirty:
79+
self._sorted_keys = sorted(self._data)
80+
self._dirty = False
81+
start = bisect_left(self._sorted_keys, lo)
82+
stop = bisect_left(self._sorted_keys, hi, lo=start)
83+
for key in self._sorted_keys[start:stop]:
84+
yield key, self._data[key]
85+
86+
def __len__(self) -> int:
87+
return len(self._data)

src/kh57/encoding.pyx

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,108 @@
11
# cython: language_level=3
22
"""kh57 encoding and recovery.
33
4-
Stub. Algorithm implementation coming later.
4+
encodes a 57-bit non-negative integer key into a 64-bit compound sort key:
5+
the top 7 bits carry a level id derived from `bit_length(uniform_hash(key))`,
6+
the bottom 57 bits carry the original key. the first bit is zero by
7+
construction, keeping the sign bit free so big-endian encoding preserves
8+
order even where keys are treated as signed. within a level the original
9+
key order is preserved; levels are deterministic random subsets of the key
10+
space, with each higher level holding roughly twice as many keys.
511
"""
12+
13+
from libc.stdint cimport int64_t, uint8_t
14+
15+
from kh57.siphash cimport isiphash64
16+
17+
18+
SALT = b"0123456789abcdef"
19+
20+
cdef unsigned char* _SALT = SALT
21+
22+
23+
cpdef int64_t uniform_hash(int64_t key):
24+
"""Uniform, not necessarily cryptographic, hash of a signed 64-bit int.
25+
26+
uses siphash-2-4 underneath: 64-bit message, 16-byte key (the module
27+
SALT), 64-bit hash out.
28+
"""
29+
return isiphash64(key, _SALT)
30+
31+
32+
def _uniform_hash_with_salt(key: int, salt: bytes) -> int:
33+
"""Like `uniform_hash` but with an explicit 16-byte salt.
34+
35+
the top-level `uniform_hash` is intentionally not parametrized: the salt
36+
determines the level structure, so it must stay constant for a dataset.
37+
use this only if you need a different (but again constant) salt.
38+
"""
39+
if len(salt) != 16:
40+
raise ValueError("salt must be exactly 16 bytes")
41+
cdef const uint8_t[:] s = salt
42+
return isiphash64(key, <uint8_t*>&s[0])
43+
44+
45+
def kh57(key: int) -> int:
46+
"""Encode a 57-bit non-negative integer key into a 64-bit compound key.
47+
48+
Args:
49+
key: non-negative integer in the range [0, 2^57 - 1].
50+
51+
Returns:
52+
the 64-bit encoded key: 7 level bits then 57 key bits.
53+
54+
Raises:
55+
ValueError: if the key is negative or does not fit in 57 bits.
56+
"""
57+
# supports range 0, 1, 2, ..., (2^57 - 2), (2^57 - 1)
58+
if key < 0:
59+
raise ValueError("key must be non-negative")
60+
if key >> 57:
61+
raise ValueError("key must fit in 57 bits")
62+
63+
some_deterministic_hash = uniform_hash(key)
64+
65+
# Cast to unsigned int
66+
buffer = some_deterministic_hash.to_bytes(8, "big", signed=True)
67+
some_deterministic_hash = int.from_bytes(buffer, "big", signed=False)
68+
69+
# Take the most significant bit as the level id.
70+
# Higher levels hold most of the records; the highest available level
71+
# holds almost half of them.
72+
level_idx = some_deterministic_hash.bit_length()
73+
74+
if level_idx:
75+
# Levels with id N are stored as (N-1) to avoid wasting a bit:
76+
# 2^6 = 64 needs 7 bits, but level 0 is a near-impossible
77+
# special case (hash == 0), so the ids can be shifted down safely.
78+
level_idx -= 1
79+
80+
# Format: 8 bytes in total
81+
# +-------+---------------------------------------------------------------+
82+
# | 7 bit | . . . . . 57 bits |
83+
# | level | . . . . . big-endian |
84+
# | id | . . . . .unsigned integer |
85+
# +-------+---------------------------------------------------------------+
86+
# | 0 . 1 . 2 . 3 . 4 . 5 . 6 . 7 |
87+
# +-----------------------------------------------------------------------+
88+
# Note: the first bit is zero by design, so storage layers that treat
89+
# the first bit as a sign bit still sort correctly: big-endian
90+
# encoding preserves the order.
91+
h = (level_idx << 57) | key
92+
93+
return h
94+
95+
96+
def recover(h: int) -> tuple[int, int]:
97+
"""Recover the level id and the original key from a `kh57` encoded key.
98+
99+
Args:
100+
h: the 64-bit encoded key returned by `kh57`.
101+
102+
Returns:
103+
(level_id, key) tuple.
104+
"""
105+
level_idx = h >> 57
106+
mask = (1 << 57) - 1
107+
key = h & mask
108+
return level_idx, key

src/kh57/sampling.pyx

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,108 @@
11
# cython: language_level=3
2-
"""Range reservoir sampling over sorted KV datasets.
2+
# cython: annotation_typing=False
3+
# cython: embedsignature=True
4+
"""Range reservoir sampling over kh57-encoded sorted KV backends.
35
4-
Stub. Algorithm implementation coming later.
6+
backends store items under 8-byte big-endian `kh57(key)` encoded keys, so
7+
physical order is: level 0 slice, level 1 slice, ..., each slice internally
8+
in original key order. `sample()` walks that physical order, restricted to
9+
the queried key range on every level, and stops as soon as it has enough.
510
"""
11+
12+
import random
13+
14+
15+
_MASK57 = (1 << 57) - 1
16+
_KEYSPACE = 1 << 57
17+
# 7 level bits give 128 possible levels; the current encoding only ever
18+
# produces 0..63, the rest scan as empty slices.
19+
_LEVELS = 128
20+
21+
_default_rng = random.Random()
22+
23+
24+
def sample(
25+
backend,
26+
n: int,
27+
begin: int | None = None,
28+
end: int | None = None,
29+
*,
30+
rng: random.Random | None = None,
31+
) -> list:
32+
"""Pull up to `n` uniformly-sampled (key, value) pairs from a sub-range.
33+
34+
walks the levels of a kh57-encoded backend in physical (ascending) order:
35+
the sparsest levels come first, each next level holds roughly twice as
36+
many in-range items. every level whose slice fits in the remaining quota
37+
is taken whole; the first level that overflows it gets reservoir-sampled
38+
for the remainder, then iteration stops. each level is a deterministic
39+
uniform subset of the range, so the union is a uniform sample, total
40+
reads stay within ~2x of `n`, and appends outside `[begin, end)` never
41+
touch the scanned slices (stability).
42+
43+
Args:
44+
backend: object implementing the `kh57.backends.Backend` protocol,
45+
holding values under 8-byte big-endian `kh57(key)` encoded keys.
46+
n: number of items to sample. fewer are returned if the range holds
47+
fewer than `n` items.
48+
begin: inclusive range start as an original (not encoded) int key.
49+
None means unbounded from below.
50+
end: exclusive range end as an original int key. None means
51+
unbounded from above.
52+
rng: pass a seeded `random.Random` for deterministic sampling
53+
(default: a module-level instance).
54+
55+
Returns:
56+
list of (original_key, value) pairs. Order is unspecified.
57+
"""
58+
if n < 0:
59+
raise ValueError("n must be non-negative")
60+
if n == 0:
61+
return []
62+
63+
lo_key = 0 if begin is None else begin
64+
hi_key = _KEYSPACE if end is None else end
65+
if lo_key < 0:
66+
raise ValueError("begin must be non-negative")
67+
if hi_key > _KEYSPACE:
68+
raise ValueError("end must be <= 2**57")
69+
if lo_key >= hi_key:
70+
return []
71+
72+
if rng is None:
73+
rng = _default_rng
74+
75+
out = []
76+
remaining = n
77+
for level in range(_LEVELS):
78+
base = level << 57
79+
lo = (base + lo_key).to_bytes(8, "big")
80+
hi_int = base + hi_key
81+
if hi_int < (1 << 64):
82+
hi = hi_int.to_bytes(8, "big")
83+
else:
84+
# level 127 with an unbounded end: 2^64 does not fit in 8 bytes,
85+
# any 9-byte string with the max 8-byte prefix sorts above all keys
86+
hi = b"\xff" * 8 + b"\x00"
87+
88+
# reservoir sampling, algorithm R: the slices this loop sees are at
89+
# most ~2x the quota by construction, so algorithm L's skip-ahead
90+
# would buy nothing over R's simplicity.
91+
reservoir = []
92+
seen = 0
93+
for encoded, value in backend.range_scan(lo, hi):
94+
item = (int.from_bytes(encoded, "big") & _MASK57, value)
95+
if seen < remaining:
96+
reservoir.append(item)
97+
else:
98+
j = rng.randrange(seen + 1)
99+
if j < remaining:
100+
reservoir[j] = item
101+
seen += 1
102+
103+
out.extend(reservoir)
104+
remaining -= len(reservoir)
105+
if remaining == 0:
106+
break
107+
108+
return out

src/kh57/siphash.pxd

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,21 @@
11
# cython: language_level=3
2-
# siphash declarations - stub.
2+
# siphash-2-4 low-level declarations, cimport-able from other kh57 modules.
3+
4+
from libc.stdint cimport int64_t, uint8_t, uint64_t
5+
6+
7+
cdef uint64_t usiphash64(
8+
uint64_t data,
9+
uint8_t* key
10+
) noexcept nogil
11+
12+
cdef int64_t isiphash64(
13+
int64_t data,
14+
uint8_t* key
15+
) noexcept nogil
16+
17+
cdef uint64_t low_level_siphash(
18+
uint8_t* data,
19+
size_t datalen,
20+
uint8_t* key
21+
) noexcept nogil

0 commit comments

Comments
 (0)