lomboksimhash 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lomboksimhash-0.1.0/PKG-INFO +47 -0
- lomboksimhash-0.1.0/README.md +19 -0
- lomboksimhash-0.1.0/pyproject.toml +87 -0
- lomboksimhash-0.1.0/setup.cfg +4 -0
- lomboksimhash-0.1.0/src/lomboksimhash/__init__.py +48 -0
- lomboksimhash-0.1.0/src/lomboksimhash/_core.py +50 -0
- lomboksimhash-0.1.0/src/lomboksimhash/_lsh.py +95 -0
- lomboksimhash-0.1.0/src/lomboksimhash/_minhash.py +77 -0
- lomboksimhash-0.1.0/src/lomboksimhash/_sha256.py +134 -0
- lomboksimhash-0.1.0/src/lomboksimhash/_shingle.py +76 -0
- lomboksimhash-0.1.0/src/lomboksimhash/_simhash.py +77 -0
- lomboksimhash-0.1.0/src/lomboksimhash.egg-info/PKG-INFO +47 -0
- lomboksimhash-0.1.0/src/lomboksimhash.egg-info/SOURCES.txt +16 -0
- lomboksimhash-0.1.0/src/lomboksimhash.egg-info/dependency_links.txt +1 -0
- lomboksimhash-0.1.0/src/lomboksimhash.egg-info/requires.txt +6 -0
- lomboksimhash-0.1.0/src/lomboksimhash.egg-info/top_level.txt +1 -0
- lomboksimhash-0.1.0/tests/test_api.py +216 -0
- lomboksimhash-0.1.0/tests/test_vectors.py +134 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lomboksimhash
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Locality-sensitive hashing for near-duplicate document detection. SimHash, MinHash, LSH, SHA-256, and shingling.
|
|
5
|
+
Author-email: CodingLombok <codinglombok@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/codinglombok/LombokSimHash
|
|
8
|
+
Project-URL: Repository, https://github.com/codinglombok/LombokSimHash.git
|
|
9
|
+
Project-URL: Changelog, https://github.com/codinglombok/LombokSimHash/blob/main/changelog.md
|
|
10
|
+
Keywords: simhash,minhash,lsh,deduplication,hashing,locality-sensitive-hashing,near-duplicate,lombok
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy>=1.13; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# LombokSimHash (Python)
|
|
30
|
+
|
|
31
|
+
Near-duplicate document detection for RAG pipelines — SimHash, MinHash, LSH, SHA-256, and shingling. Pure Python, zero dependencies.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install lomboksimhash
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from lomboksimhash import simhash64_text, hamming64, MinHasher, jaccard_estimate, LshIndex
|
|
39
|
+
|
|
40
|
+
a = simhash64_text("the quick brown fox jumps over the lazy dog")
|
|
41
|
+
b = simhash64_text("the quick brown cat jumps over the lazy dog")
|
|
42
|
+
print(hamming64(a, b)) # 8
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Part of [LombokRAGFrameworks](https://github.com/codinglombok/LombokSimHash). Full docs in the [main repository](https://github.com/codinglombok/LombokSimHash).
|
|
46
|
+
|
|
47
|
+
License: Apache-2.0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# LombokSimHash (Python)
|
|
2
|
+
|
|
3
|
+
Near-duplicate document detection for RAG pipelines — SimHash, MinHash, LSH, SHA-256, and shingling. Pure Python, zero dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install lomboksimhash
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from lomboksimhash import simhash64_text, hamming64, MinHasher, jaccard_estimate, LshIndex
|
|
11
|
+
|
|
12
|
+
a = simhash64_text("the quick brown fox jumps over the lazy dog")
|
|
13
|
+
b = simhash64_text("the quick brown cat jumps over the lazy dog")
|
|
14
|
+
print(hamming64(a, b)) # 8
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Part of [LombokRAGFrameworks](https://github.com/codinglombok/LombokSimHash). Full docs in the [main repository](https://github.com/codinglombok/LombokSimHash).
|
|
18
|
+
|
|
19
|
+
License: Apache-2.0
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lomboksimhash"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Locality-sensitive hashing for near-duplicate document detection. SimHash, MinHash, LSH, SHA-256, and shingling."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "CodingLombok", email = "codinglombok@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"simhash",
|
|
17
|
+
"minhash",
|
|
18
|
+
"lsh",
|
|
19
|
+
"deduplication",
|
|
20
|
+
"hashing",
|
|
21
|
+
"locality-sensitive-hashing",
|
|
22
|
+
"near-duplicate",
|
|
23
|
+
"lombok",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: Apache Software License",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Topic :: Text Processing :: Indexing",
|
|
35
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
36
|
+
]
|
|
37
|
+
dependencies = []
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/codinglombok/LombokSimHash"
|
|
41
|
+
Repository = "https://github.com/codinglombok/LombokSimHash.git"
|
|
42
|
+
Changelog = "https://github.com/codinglombok/LombokSimHash/blob/main/changelog.md"
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=8.0",
|
|
47
|
+
"pytest-cov>=5.0",
|
|
48
|
+
"ruff>=0.8",
|
|
49
|
+
"mypy>=1.13",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["src"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
addopts = "-q"
|
|
58
|
+
|
|
59
|
+
[tool.coverage.run]
|
|
60
|
+
branch = true
|
|
61
|
+
source = ["lomboksimhash"]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
show_missing = true
|
|
65
|
+
exclude_lines = [
|
|
66
|
+
"pragma: no cover",
|
|
67
|
+
"if TYPE_CHECKING:",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
line-length = 100
|
|
72
|
+
target-version = "py310"
|
|
73
|
+
src = ["src", "tests"]
|
|
74
|
+
|
|
75
|
+
[tool.ruff.lint]
|
|
76
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "RET", "SIM", "PL"]
|
|
77
|
+
ignore = ["PLR0913", "PLR2004"]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint.per-file-ignores]
|
|
80
|
+
"tests/*" = ["PLR0133", "PLR0912", "PLR0915", "SIM300"]
|
|
81
|
+
|
|
82
|
+
[tool.mypy]
|
|
83
|
+
strict = true
|
|
84
|
+
files = ["src"]
|
|
85
|
+
python_version = "3.10"
|
|
86
|
+
warn_unreachable = true
|
|
87
|
+
pretty = true
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""LombokSimHash — Python Port.
|
|
2
|
+
|
|
3
|
+
Near-duplicate document detection for RAG pipelines:
|
|
4
|
+
SHA-256, FNV-1a 64, SimHash 64/128, MinHash, LSH, shingling.
|
|
5
|
+
|
|
6
|
+
Zero dependencies. Behavior pinned by docs/SPEC_LombokSimHash_0.1.0.md;
|
|
7
|
+
all ports are validated against vectors/lomboksimhash-vectors-v2.json.
|
|
8
|
+
|
|
9
|
+
Part of LombokRAGFrameworks (Lombok Ecosystem).
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ._core import FNV64_OFFSET, FNV64_PRIME, MASK64, WHITESPACE, fnv1a64, to_hex64, tokenize
|
|
16
|
+
from ._lsh import LshIndex, band_hash
|
|
17
|
+
from ._minhash import MinHasher, jaccard_estimate
|
|
18
|
+
from ._sha256 import Sha256State, sha256, to_hex
|
|
19
|
+
from ._shingle import Shingler, char_shingles, word_shingles
|
|
20
|
+
from ._simhash import hamming64, hamming128, simhash64, simhash64_text, simhash128, simhash128_text
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"MASK64",
|
|
26
|
+
"FNV64_OFFSET",
|
|
27
|
+
"FNV64_PRIME",
|
|
28
|
+
"WHITESPACE",
|
|
29
|
+
"Sha256State",
|
|
30
|
+
"LshIndex",
|
|
31
|
+
"MinHasher",
|
|
32
|
+
"Shingler",
|
|
33
|
+
"band_hash",
|
|
34
|
+
"char_shingles",
|
|
35
|
+
"word_shingles",
|
|
36
|
+
"fnv1a64",
|
|
37
|
+
"hamming128",
|
|
38
|
+
"hamming64",
|
|
39
|
+
"jaccard_estimate",
|
|
40
|
+
"sha256",
|
|
41
|
+
"simhash128",
|
|
42
|
+
"simhash128_text",
|
|
43
|
+
"simhash64",
|
|
44
|
+
"simhash64_text",
|
|
45
|
+
"to_hex",
|
|
46
|
+
"to_hex64",
|
|
47
|
+
"tokenize",
|
|
48
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""FNV-1a 64-bit and shared helpers (tokenization, hex formatting).
|
|
2
|
+
|
|
3
|
+
Behavior pinned by docs/SPEC_LombokSimHash_0.1.0.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
FNV64_OFFSET = 0xCBF29CE484222325
|
|
9
|
+
FNV64_PRIME = 0x100000001B3
|
|
10
|
+
MASK64 = (1 << 64) - 1
|
|
11
|
+
|
|
12
|
+
# SPEC whitespace set = Unicode White_Space property (exact, cross-language).
|
|
13
|
+
WHITESPACE = frozenset(
|
|
14
|
+
"\t\n\x0b\x0c\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005"
|
|
15
|
+
"\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fnv1a64(data: bytes) -> int:
|
|
20
|
+
"""FNV-1a 64-bit hash. offset 0xcbf29ce484222325, prime 0x100000001b3."""
|
|
21
|
+
h = FNV64_OFFSET
|
|
22
|
+
for b in data:
|
|
23
|
+
h ^= b
|
|
24
|
+
h = (h * FNV64_PRIME) & MASK64
|
|
25
|
+
return h
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_hex64(value: int) -> str:
|
|
29
|
+
"""Format a u64 as 16 lowercase hex chars."""
|
|
30
|
+
return f"{value:016x}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tokenize(text: str) -> list[str]:
|
|
34
|
+
"""Split text on runs of the SPEC whitespace set; drop empty parts."""
|
|
35
|
+
return [t for t in _split_ws(text) if t]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _split_ws(text: str) -> list[str]:
|
|
39
|
+
parts: list[str] = []
|
|
40
|
+
cur: list[str] = []
|
|
41
|
+
for ch in text:
|
|
42
|
+
if ch in WHITESPACE:
|
|
43
|
+
if cur:
|
|
44
|
+
parts.append("".join(cur))
|
|
45
|
+
cur = []
|
|
46
|
+
else:
|
|
47
|
+
cur.append(ch)
|
|
48
|
+
if cur:
|
|
49
|
+
parts.append("".join(cur))
|
|
50
|
+
return parts
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""LSH — band+row locality-sensitive hashing over MinHash signatures.
|
|
2
|
+
|
|
3
|
+
Behavior pinned by docs/SPEC_LombokSimHash_0.1.0.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from ._core import FNV64_OFFSET, FNV64_PRIME, MASK64
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _assert_config(bands: int, rows: int) -> None:
|
|
14
|
+
if bands < 1 or rows < 1:
|
|
15
|
+
raise ValueError("[ERR_LSH_CONFIG] bands and rows must be > 0")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _assert_sig_len(signature: Sequence[int], expected: int) -> None:
|
|
19
|
+
if len(signature) != expected:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"[ERR_SIG_LEN] signature length must be bands * rows = {expected}"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def band_hash(band_values: Sequence[int]) -> int:
|
|
26
|
+
"""FNV-1a 64 over the little-endian bytes of each u64 in the band."""
|
|
27
|
+
h = FNV64_OFFSET
|
|
28
|
+
for val in band_values:
|
|
29
|
+
for byte_idx in range(8):
|
|
30
|
+
byte = (val >> (byte_idx * 8)) & 0xFF
|
|
31
|
+
h ^= byte
|
|
32
|
+
h = (h * FNV64_PRIME) & MASK64
|
|
33
|
+
return h
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LshIndex:
|
|
37
|
+
"""LSH index (band+row) over MinHash signatures.
|
|
38
|
+
|
|
39
|
+
query() returns de-duplicated doc IDs sorted ascending by code point.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("_bands", "_rows", "_buckets")
|
|
43
|
+
|
|
44
|
+
def __init__(self, bands: int, rows: int) -> None:
|
|
45
|
+
_assert_config(bands, rows)
|
|
46
|
+
self._bands = bands
|
|
47
|
+
self._rows = rows
|
|
48
|
+
self._buckets: list[dict[int, list[str]]] = [{} for _ in range(bands)]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def bands(self) -> int:
|
|
52
|
+
return self._bands
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def rows(self) -> int:
|
|
56
|
+
return self._rows
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def signature_len(self) -> int:
|
|
60
|
+
return self._bands * self._rows
|
|
61
|
+
|
|
62
|
+
def threshold(self) -> float:
|
|
63
|
+
"""Approximate Jaccard threshold (1/bands)^(1/rows). Informational."""
|
|
64
|
+
base = 1.0 / self._bands
|
|
65
|
+
expo = 1.0 / self._rows
|
|
66
|
+
return float(base ** expo)
|
|
67
|
+
|
|
68
|
+
def insert(self, doc_id: str, signature: Sequence[int]) -> None:
|
|
69
|
+
"""Insert a document signature into every band."""
|
|
70
|
+
_assert_sig_len(signature, self.signature_len)
|
|
71
|
+
for b in range(self._bands):
|
|
72
|
+
start = b * self._rows
|
|
73
|
+
key = band_hash(signature[start:start + self._rows])
|
|
74
|
+
self._buckets[b].setdefault(key, []).append(doc_id)
|
|
75
|
+
|
|
76
|
+
def query(self, signature: Sequence[int]) -> list[str]:
|
|
77
|
+
"""Return sorted unique candidate doc IDs for the signature."""
|
|
78
|
+
_assert_sig_len(signature, self.signature_len)
|
|
79
|
+
candidates: set[str] = set()
|
|
80
|
+
for b in range(self._bands):
|
|
81
|
+
start = b * self._rows
|
|
82
|
+
key = band_hash(signature[start:start + self._rows])
|
|
83
|
+
candidates.update(self._buckets[b].get(key, ()))
|
|
84
|
+
return sorted(candidates)
|
|
85
|
+
|
|
86
|
+
def remove(self, doc_id: str, signature: Sequence[int]) -> None:
|
|
87
|
+
"""Remove a doc ID from every band bucket touched by the signature."""
|
|
88
|
+
_assert_sig_len(signature, self.signature_len)
|
|
89
|
+
for b in range(self._bands):
|
|
90
|
+
start = b * self._rows
|
|
91
|
+
key = band_hash(signature[start:start + self._rows])
|
|
92
|
+
if key in self._buckets[b]:
|
|
93
|
+
self._buckets[b][key] = [
|
|
94
|
+
d for d in self._buckets[b][key] if d != doc_id
|
|
95
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""MinHash — Jaccard estimation via deterministic k-permutation family.
|
|
2
|
+
|
|
3
|
+
Behavior pinned by docs/SPEC_LombokSimHash_0.1.0.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from ._core import MASK64, fnv1a64
|
|
11
|
+
|
|
12
|
+
MERSENNE_61 = (1 << 61) - 1
|
|
13
|
+
LCG_SEED = 0x517CC1B727220A95
|
|
14
|
+
LCG_MUL = 6364136223846793005
|
|
15
|
+
LCG_INC = 1442695040888963407
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _assert_num_perm(num_perm: int) -> None:
|
|
19
|
+
if num_perm < 1:
|
|
20
|
+
raise ValueError("[ERR_MINHASH_PERM] num_perm must be > 0")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _generate_hash_params(num_perm: int) -> list[tuple[int, int]]:
|
|
24
|
+
state = LCG_SEED
|
|
25
|
+
params: list[tuple[int, int]] = []
|
|
26
|
+
for _ in range(num_perm):
|
|
27
|
+
state = (state * LCG_MUL + LCG_INC) & MASK64
|
|
28
|
+
a = state % MERSENNE_61
|
|
29
|
+
if a == 0: # pragma: no cover — defensive; LCG never yields a multiple of p
|
|
30
|
+
a = 1
|
|
31
|
+
state = (state * LCG_MUL + LCG_INC) & MASK64
|
|
32
|
+
b = state % MERSENNE_61
|
|
33
|
+
params.append((a, b))
|
|
34
|
+
return params
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _universal_hash(a: int, b: int, x: int) -> int:
|
|
38
|
+
return (a * (x % MERSENNE_61) + b) % MERSENNE_61
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MinHasher:
|
|
42
|
+
"""MinHash signature generator with a deterministic permutation family."""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("_num_perm", "_params", "signature")
|
|
45
|
+
|
|
46
|
+
def __init__(self, num_perm: int = 128) -> None:
|
|
47
|
+
_assert_num_perm(num_perm)
|
|
48
|
+
self._num_perm = num_perm
|
|
49
|
+
self._params = _generate_hash_params(num_perm)
|
|
50
|
+
self.signature = [MASK64] * num_perm
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def num_perm(self) -> int:
|
|
54
|
+
return self._num_perm
|
|
55
|
+
|
|
56
|
+
def add(self, token: str) -> None:
|
|
57
|
+
"""Fold one token into the signature."""
|
|
58
|
+
h = fnv1a64(token.encode("utf-8"))
|
|
59
|
+
for i in range(self._num_perm):
|
|
60
|
+
a, b = self._params[i]
|
|
61
|
+
val = _universal_hash(a, b, h)
|
|
62
|
+
self.signature[i] = min(self.signature[i], val)
|
|
63
|
+
|
|
64
|
+
def add_tokens(self, tokens: Sequence[str]) -> None:
|
|
65
|
+
"""Fold a sequence of tokens into the signature (order-independent)."""
|
|
66
|
+
for t in tokens:
|
|
67
|
+
self.add(t)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def jaccard_estimate(sig_a: Sequence[int], sig_b: Sequence[int]) -> float:
|
|
71
|
+
"""Estimated Jaccard = matched positions / signature length."""
|
|
72
|
+
if len(sig_a) != len(sig_b):
|
|
73
|
+
raise ValueError("[ERR_JACCARD_LEN] signatures must have same length")
|
|
74
|
+
if len(sig_a) == 0:
|
|
75
|
+
raise ValueError("[ERR_JACCARD_EMPTY] signatures must not be empty")
|
|
76
|
+
matches = sum(1 for a, b in zip(sig_a, sig_b, strict=False) if a == b)
|
|
77
|
+
return matches / len(sig_a)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""SHA-256 (FIPS 180-4) — pure implementation.
|
|
2
|
+
|
|
3
|
+
Behavior pinned by docs/SPEC_LombokSimHash_0.1.0.md; vectors in
|
|
4
|
+
vectors/lomboksimhash-vectors-v2.json.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import struct
|
|
10
|
+
|
|
11
|
+
_MASK32 = 0xFFFFFFFF
|
|
12
|
+
|
|
13
|
+
_K_SHA = [
|
|
14
|
+
0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5,
|
|
15
|
+
0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
|
|
16
|
+
0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3,
|
|
17
|
+
0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
|
|
18
|
+
0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC,
|
|
19
|
+
0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
|
|
20
|
+
0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7,
|
|
21
|
+
0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
|
|
22
|
+
0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13,
|
|
23
|
+
0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
|
|
24
|
+
0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3,
|
|
25
|
+
0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
|
|
26
|
+
0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5,
|
|
27
|
+
0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
|
|
28
|
+
0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208,
|
|
29
|
+
0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2,
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_H_INIT = [
|
|
33
|
+
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
|
|
34
|
+
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rotr32(x: int, n: int) -> int:
|
|
39
|
+
return ((x >> n) | (x << (32 - n))) & _MASK32
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _sha256_compress(state: list[int], block: bytes) -> None:
|
|
43
|
+
w = [0] * 64
|
|
44
|
+
for i in range(16):
|
|
45
|
+
w[i] = struct.unpack(">I", block[i * 4:i * 4 + 4])[0]
|
|
46
|
+
for i in range(16, 64):
|
|
47
|
+
s0 = _rotr32(w[i - 15], 7) ^ _rotr32(w[i - 15], 18) ^ (w[i - 15] >> 3)
|
|
48
|
+
s1 = _rotr32(w[i - 2], 17) ^ _rotr32(w[i - 2], 19) ^ (w[i - 2] >> 10)
|
|
49
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) & _MASK32
|
|
50
|
+
|
|
51
|
+
a, b, c, d, e, f, g, h = state
|
|
52
|
+
|
|
53
|
+
for i in range(64):
|
|
54
|
+
s1 = _rotr32(e, 6) ^ _rotr32(e, 11) ^ _rotr32(e, 25)
|
|
55
|
+
ch = (e & f) ^ ((~e & _MASK32) & g)
|
|
56
|
+
temp1 = (h + s1 + ch + _K_SHA[i] + w[i]) & _MASK32
|
|
57
|
+
s0 = _rotr32(a, 2) ^ _rotr32(a, 13) ^ _rotr32(a, 22)
|
|
58
|
+
maj = (a & b) ^ (a & c) ^ (b & c)
|
|
59
|
+
temp2 = (s0 + maj) & _MASK32
|
|
60
|
+
|
|
61
|
+
h = g
|
|
62
|
+
g = f
|
|
63
|
+
f = e
|
|
64
|
+
e = (d + temp1) & _MASK32
|
|
65
|
+
d = c
|
|
66
|
+
c = b
|
|
67
|
+
b = a
|
|
68
|
+
a = (temp1 + temp2) & _MASK32
|
|
69
|
+
|
|
70
|
+
for i, v in enumerate((a, b, c, d, e, f, g, h)):
|
|
71
|
+
state[i] = (state[i] + v) & _MASK32
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Sha256State:
|
|
75
|
+
"""Incremental SHA-256 hasher (streaming)."""
|
|
76
|
+
|
|
77
|
+
__slots__ = ("_state", "_buffer", "_total_len")
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
self._state = list(_H_INIT)
|
|
81
|
+
self._buffer = bytearray()
|
|
82
|
+
self._total_len = 0
|
|
83
|
+
|
|
84
|
+
def update(self, data: bytes | bytearray | memoryview) -> None:
|
|
85
|
+
self._total_len += len(data)
|
|
86
|
+
self._buffer.extend(data)
|
|
87
|
+
while len(self._buffer) >= 64:
|
|
88
|
+
block = bytes(self._buffer[:64])
|
|
89
|
+
del self._buffer[:64]
|
|
90
|
+
_sha256_compress(self._state, block)
|
|
91
|
+
|
|
92
|
+
def digest(self) -> bytes:
|
|
93
|
+
"""Finalize (without destroying state) and return the 32-byte digest."""
|
|
94
|
+
state = list(self._state)
|
|
95
|
+
buffer = bytearray(self._buffer)
|
|
96
|
+
bit_len = self._total_len * 8
|
|
97
|
+
buffer.append(0x80)
|
|
98
|
+
while len(buffer) % 64 != 56:
|
|
99
|
+
buffer.append(0x00)
|
|
100
|
+
buffer.extend(bit_len.to_bytes(8, "big"))
|
|
101
|
+
for i in range(0, len(buffer), 64):
|
|
102
|
+
_sha256_compress(state, bytes(buffer[i:i + 64]))
|
|
103
|
+
return b"".join(v.to_bytes(4, "big") for v in state)
|
|
104
|
+
|
|
105
|
+
def hexdigest(self) -> str:
|
|
106
|
+
return self.digest().hex()
|
|
107
|
+
|
|
108
|
+
def finalize(self) -> bytes:
|
|
109
|
+
"""Finalize and return the 32-byte digest."""
|
|
110
|
+
return self.digest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sha256(data: bytes | str) -> str:
|
|
114
|
+
"""Compute SHA-256 of bytes or UTF-8 text. Returns lowercase hex string."""
|
|
115
|
+
if isinstance(data, str):
|
|
116
|
+
data = data.encode("utf-8")
|
|
117
|
+
state = list(_H_INIT)
|
|
118
|
+
bit_len = len(data) * 8
|
|
119
|
+
|
|
120
|
+
padded = bytearray(data)
|
|
121
|
+
padded.append(0x80)
|
|
122
|
+
while len(padded) % 64 != 56:
|
|
123
|
+
padded.append(0x00)
|
|
124
|
+
padded.extend(bit_len.to_bytes(8, "big"))
|
|
125
|
+
|
|
126
|
+
for i in range(0, len(padded), 64):
|
|
127
|
+
_sha256_compress(state, bytes(padded[i:i + 64]))
|
|
128
|
+
|
|
129
|
+
return "".join(f"{v:08x}" for v in state)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def to_hex(digest: bytes) -> str:
|
|
133
|
+
"""Convert digest bytes to lowercase hex string."""
|
|
134
|
+
return digest.hex()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Shingling — character/word n-gram generation (streaming).
|
|
2
|
+
|
|
3
|
+
Behavior pinned by docs/SPEC_LombokSimHash_0.1.0.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ._core import tokenize
|
|
9
|
+
|
|
10
|
+
_SHINGLE_N_MSG = "[ERR_SHINGLE_N] shingle size must be > 0"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _assert_n(n: int) -> None:
|
|
14
|
+
if n < 1:
|
|
15
|
+
raise ValueError(_SHINGLE_N_MSG)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Shingler:
|
|
19
|
+
"""Streaming shingle generator.
|
|
20
|
+
|
|
21
|
+
Character mode slices Unicode code points; word mode slices tokens
|
|
22
|
+
produced by the SPEC tokenizer and joins them with a single U+0020.
|
|
23
|
+
finish() returns unique shingles sorted by code point.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__slots__ = ("_mode", "_n", "_buffer")
|
|
27
|
+
|
|
28
|
+
def __init__(self, mode: str = "character", n: int = 3) -> None:
|
|
29
|
+
_assert_n(n)
|
|
30
|
+
if mode not in ("character", "word"):
|
|
31
|
+
raise ValueError("mode must be 'character' or 'word'")
|
|
32
|
+
self._mode = mode
|
|
33
|
+
self._n = n
|
|
34
|
+
self._buffer = ""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def mode(self) -> str:
|
|
38
|
+
return self._mode
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def n(self) -> int:
|
|
42
|
+
return self._n
|
|
43
|
+
|
|
44
|
+
def feed(self, text: str) -> None:
|
|
45
|
+
self._buffer += text
|
|
46
|
+
|
|
47
|
+
def finish(self) -> list[str]:
|
|
48
|
+
"""Return sorted unique shingles."""
|
|
49
|
+
if self._mode == "character":
|
|
50
|
+
chars = list(self._buffer)
|
|
51
|
+
count = len(chars)
|
|
52
|
+
return sorted(
|
|
53
|
+
{"".join(chars[i:i + self._n]) for i in range(count - self._n + 1)}
|
|
54
|
+
)
|
|
55
|
+
words = tokenize(self._buffer)
|
|
56
|
+
count = len(words)
|
|
57
|
+
return sorted(
|
|
58
|
+
{" ".join(words[i:i + self._n]) for i in range(count - self._n + 1)}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def feed_finish(self, text: str) -> list[str]:
|
|
62
|
+
"""Convenience: feed(text) then finish()."""
|
|
63
|
+
self.feed(text)
|
|
64
|
+
return self.finish()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def char_shingles(text: str, n: int) -> list[str]:
|
|
68
|
+
"""Convenience: sorted unique character n-grams (code points)."""
|
|
69
|
+
_assert_n(n)
|
|
70
|
+
return Shingler("character", n).feed_finish(text)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def word_shingles(text: str, n: int) -> list[str]:
|
|
74
|
+
"""Convenience: sorted unique word n-grams."""
|
|
75
|
+
_assert_n(n)
|
|
76
|
+
return Shingler("word", n).feed_finish(text)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""SimHash — 64/128-bit locality-sensitive fingerprinting via FNV-1a.
|
|
2
|
+
|
|
3
|
+
Behavior pinned by docs/SPEC_LombokSimHash_0.1.0.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from ._core import fnv1a64, tokenize
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def simhash64(tokens: Sequence[str]) -> int:
|
|
14
|
+
"""64-bit SimHash over token list. Bit i set iff weight[i] > 0 (LSB first)."""
|
|
15
|
+
v = [0] * 64
|
|
16
|
+
for token in tokens:
|
|
17
|
+
h = fnv1a64(token.encode("utf-8"))
|
|
18
|
+
for i in range(64):
|
|
19
|
+
if (h >> i) & 1:
|
|
20
|
+
v[i] += 1
|
|
21
|
+
else:
|
|
22
|
+
v[i] -= 1
|
|
23
|
+
result = 0
|
|
24
|
+
for i in range(64):
|
|
25
|
+
if v[i] > 0:
|
|
26
|
+
result |= 1 << i
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def simhash64_text(text: str) -> int:
|
|
31
|
+
"""Tokenize by the SPEC whitespace set, then simhash64."""
|
|
32
|
+
return simhash64(tokenize(text))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def simhash128(tokens: Sequence[str]) -> tuple[int, int]:
|
|
36
|
+
"""128-bit SimHash. Returns (hi, lo).
|
|
37
|
+
|
|
38
|
+
lo = simhash of fnv1a64(utf8(token)); hi = simhash of
|
|
39
|
+
fnv1a64(utf8(token) || 0xFF).
|
|
40
|
+
"""
|
|
41
|
+
v = [0] * 128
|
|
42
|
+
for token in tokens:
|
|
43
|
+
raw = token.encode("utf-8")
|
|
44
|
+
h1 = fnv1a64(raw)
|
|
45
|
+
h2 = fnv1a64(raw + b"\xff")
|
|
46
|
+
for i in range(64):
|
|
47
|
+
if (h1 >> i) & 1:
|
|
48
|
+
v[i] += 1
|
|
49
|
+
else:
|
|
50
|
+
v[i] -= 1
|
|
51
|
+
if (h2 >> i) & 1:
|
|
52
|
+
v[64 + i] += 1
|
|
53
|
+
else:
|
|
54
|
+
v[64 + i] -= 1
|
|
55
|
+
lo = 0
|
|
56
|
+
hi = 0
|
|
57
|
+
for i in range(64):
|
|
58
|
+
if v[i] > 0:
|
|
59
|
+
lo |= 1 << i
|
|
60
|
+
if v[64 + i] > 0:
|
|
61
|
+
hi |= 1 << i
|
|
62
|
+
return (hi, lo)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def simhash128_text(text: str) -> tuple[int, int]:
|
|
66
|
+
"""Tokenize by the SPEC whitespace set, then simhash128."""
|
|
67
|
+
return simhash128(tokenize(text))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def hamming64(a: int, b: int) -> int:
|
|
71
|
+
"""Hamming distance between two 64-bit values."""
|
|
72
|
+
return bin(a ^ b).count("1")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def hamming128(a: tuple[int, int], b: tuple[int, int]) -> int:
|
|
76
|
+
"""Hamming distance between two 128-bit (hi, lo) pairs."""
|
|
77
|
+
return bin(a[0] ^ b[0]).count("1") + bin(a[1] ^ b[1]).count("1")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lomboksimhash
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Locality-sensitive hashing for near-duplicate document detection. SimHash, MinHash, LSH, SHA-256, and shingling.
|
|
5
|
+
Author-email: CodingLombok <codinglombok@gmail.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/codinglombok/LombokSimHash
|
|
8
|
+
Project-URL: Repository, https://github.com/codinglombok/LombokSimHash.git
|
|
9
|
+
Project-URL: Changelog, https://github.com/codinglombok/LombokSimHash/blob/main/changelog.md
|
|
10
|
+
Keywords: simhash,minhash,lsh,deduplication,hashing,locality-sensitive-hashing,near-duplicate,lombok
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy>=1.13; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# LombokSimHash (Python)
|
|
30
|
+
|
|
31
|
+
Near-duplicate document detection for RAG pipelines — SimHash, MinHash, LSH, SHA-256, and shingling. Pure Python, zero dependencies.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install lomboksimhash
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from lomboksimhash import simhash64_text, hamming64, MinHasher, jaccard_estimate, LshIndex
|
|
39
|
+
|
|
40
|
+
a = simhash64_text("the quick brown fox jumps over the lazy dog")
|
|
41
|
+
b = simhash64_text("the quick brown cat jumps over the lazy dog")
|
|
42
|
+
print(hamming64(a, b)) # 8
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Part of [LombokRAGFrameworks](https://github.com/codinglombok/LombokSimHash). Full docs in the [main repository](https://github.com/codinglombok/LombokSimHash).
|
|
46
|
+
|
|
47
|
+
License: Apache-2.0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/lomboksimhash/__init__.py
|
|
4
|
+
src/lomboksimhash/_core.py
|
|
5
|
+
src/lomboksimhash/_lsh.py
|
|
6
|
+
src/lomboksimhash/_minhash.py
|
|
7
|
+
src/lomboksimhash/_sha256.py
|
|
8
|
+
src/lomboksimhash/_shingle.py
|
|
9
|
+
src/lomboksimhash/_simhash.py
|
|
10
|
+
src/lomboksimhash.egg-info/PKG-INFO
|
|
11
|
+
src/lomboksimhash.egg-info/SOURCES.txt
|
|
12
|
+
src/lomboksimhash.egg-info/dependency_links.txt
|
|
13
|
+
src/lomboksimhash.egg-info/requires.txt
|
|
14
|
+
src/lomboksimhash.egg-info/top_level.txt
|
|
15
|
+
tests/test_api.py
|
|
16
|
+
tests/test_vectors.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lomboksimhash
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Unit + API tests for the Python port (error contract, edge cases)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from lomboksimhash import (
|
|
8
|
+
LshIndex,
|
|
9
|
+
MinHasher,
|
|
10
|
+
Shingler,
|
|
11
|
+
char_shingles,
|
|
12
|
+
fnv1a64,
|
|
13
|
+
hamming64,
|
|
14
|
+
hamming128,
|
|
15
|
+
jaccard_estimate,
|
|
16
|
+
sha256,
|
|
17
|
+
simhash64,
|
|
18
|
+
simhash64_text,
|
|
19
|
+
simhash128,
|
|
20
|
+
simhash128_text,
|
|
21
|
+
tokenize,
|
|
22
|
+
word_shingles,
|
|
23
|
+
)
|
|
24
|
+
from lomboksimhash._core import to_hex64
|
|
25
|
+
from lomboksimhash._sha256 import Sha256State, to_hex
|
|
26
|
+
from lomboksimhash._sha256 import sha256 as sha256_fn
|
|
27
|
+
|
|
28
|
+
FOX = "the quick brown fox jumps over the lazy dog"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestSha256:
|
|
32
|
+
def test_reference_digests(self) -> None:
|
|
33
|
+
assert sha256(b"") == (
|
|
34
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
35
|
+
)
|
|
36
|
+
assert sha256("abc") == (
|
|
37
|
+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def test_str_and_bytes_identical(self) -> None:
|
|
41
|
+
assert sha256("héllo") == sha256("héllo".encode())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestFnv:
|
|
45
|
+
def test_offset(self) -> None:
|
|
46
|
+
assert fnv1a64(b"") == 0xCBF29CE484222325
|
|
47
|
+
|
|
48
|
+
def test_known(self) -> None:
|
|
49
|
+
assert f"{fnv1a64(b'hello'):016x}" == "a430d84680aabd0b"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestTokenize:
|
|
53
|
+
def test_spec_whitespace(self) -> None:
|
|
54
|
+
assert tokenize("a\u00a0b\u3000c\u000bd") == ["a", "b", "c", "d"]
|
|
55
|
+
|
|
56
|
+
def test_excludes_zwsp_and_bom(self) -> None:
|
|
57
|
+
assert tokenize("a\u200bb") == ["a\u200bb"]
|
|
58
|
+
assert tokenize("a\ufeffb") == ["a\ufeffb"]
|
|
59
|
+
|
|
60
|
+
def test_empty(self) -> None:
|
|
61
|
+
assert tokenize("") == []
|
|
62
|
+
assert tokenize(" \t\n") == []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestShingling:
|
|
66
|
+
def test_char_and_word(self) -> None:
|
|
67
|
+
assert char_shingles("abcde", 3) == ["abc", "bcd", "cde"]
|
|
68
|
+
assert word_shingles("a b\t\tc\n\nc", 2) == ["a b", "b c", "c c"]
|
|
69
|
+
|
|
70
|
+
def test_streaming_pure(self) -> None:
|
|
71
|
+
s = Shingler("character", 3)
|
|
72
|
+
s.feed("abc")
|
|
73
|
+
s.feed("de")
|
|
74
|
+
assert s.finish() == ["abc", "bcd", "cde"]
|
|
75
|
+
assert s.finish() == ["abc", "bcd", "cde"] # finish() idempotent
|
|
76
|
+
|
|
77
|
+
def test_n_exceeds_len(self) -> None:
|
|
78
|
+
assert char_shingles("ab", 5) == []
|
|
79
|
+
assert word_shingles("a b", 4) == []
|
|
80
|
+
|
|
81
|
+
def test_err_shingle_n(self) -> None:
|
|
82
|
+
with pytest.raises(ValueError, match=r"^\[ERR_SHINGLE_N\] shingle size must be > 0$"):
|
|
83
|
+
Shingler("character", 0)
|
|
84
|
+
with pytest.raises(ValueError, match=r"^\[ERR_SHINGLE_N\]"):
|
|
85
|
+
char_shingles("abc", 0)
|
|
86
|
+
with pytest.raises(ValueError, match=r"^\[ERR_SHINGLE_N\]"):
|
|
87
|
+
word_shingles("abc", 0)
|
|
88
|
+
|
|
89
|
+
def test_bad_mode(self) -> None:
|
|
90
|
+
with pytest.raises(ValueError, match="mode"):
|
|
91
|
+
Shingler("bogus", 2)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestSimhash:
|
|
95
|
+
def test_known(self) -> None:
|
|
96
|
+
assert f"{simhash64_text(FOX):016x}" == "cab7991c5475edee"
|
|
97
|
+
assert f"{simhash64_text(FOX.replace('fox', 'cat')):016x}" == "c2e7991c1465e56f"
|
|
98
|
+
|
|
99
|
+
def test_empty(self) -> None:
|
|
100
|
+
assert simhash64([]) == 0
|
|
101
|
+
assert simhash64_text(" \t\n") == 0
|
|
102
|
+
assert simhash128([]) == (0, 0)
|
|
103
|
+
|
|
104
|
+
def test_hamming(self) -> None:
|
|
105
|
+
fox = simhash64_text(FOX)
|
|
106
|
+
cat = simhash64_text(FOX.replace("fox", "cat"))
|
|
107
|
+
assert hamming64(fox, cat) == 8
|
|
108
|
+
assert hamming64(fox, fox) == 0
|
|
109
|
+
|
|
110
|
+
def test_128_lo_equals_64(self) -> None:
|
|
111
|
+
hi, lo = simhash128_text(FOX)
|
|
112
|
+
assert lo == simhash64_text(FOX)
|
|
113
|
+
assert hi >= 0 and hi < (1 << 64)
|
|
114
|
+
assert hamming128((hi, lo), (hi, lo)) == 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestMinHash:
|
|
118
|
+
def test_identical(self) -> None:
|
|
119
|
+
a = MinHasher(128)
|
|
120
|
+
b = MinHasher(128)
|
|
121
|
+
a.add_tokens(["the", "quick", "brown", "fox"])
|
|
122
|
+
b.add_tokens(["the", "quick", "brown", "fox"])
|
|
123
|
+
assert jaccard_estimate(a.signature, b.signature) == 1.0
|
|
124
|
+
|
|
125
|
+
def test_order_independent_and_idempotent(self) -> None:
|
|
126
|
+
a = MinHasher(64)
|
|
127
|
+
b = MinHasher(64)
|
|
128
|
+
a.add_tokens(["hello", "world"])
|
|
129
|
+
b.add_tokens(["world", "hello", "hello"])
|
|
130
|
+
assert a.signature == b.signature
|
|
131
|
+
|
|
132
|
+
def test_errors(self) -> None:
|
|
133
|
+
with pytest.raises(ValueError, match=r"^\[ERR_MINHASH_PERM\] num_perm must be > 0$"):
|
|
134
|
+
MinHasher(0)
|
|
135
|
+
a = MinHasher(8)
|
|
136
|
+
b = MinHasher(16)
|
|
137
|
+
with pytest.raises(ValueError, match=r"^\[ERR_JACCARD_LEN\]"):
|
|
138
|
+
jaccard_estimate(a.signature, b.signature)
|
|
139
|
+
with pytest.raises(ValueError, match=r"^\[ERR_JACCARD_EMPTY\]"):
|
|
140
|
+
jaccard_estimate([], [])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestLsh:
|
|
144
|
+
def test_errors(self) -> None:
|
|
145
|
+
with pytest.raises(ValueError, match=r"^\[ERR_LSH_CONFIG\]"):
|
|
146
|
+
LshIndex(0, 5)
|
|
147
|
+
with pytest.raises(ValueError, match=r"^\[ERR_LSH_CONFIG\]"):
|
|
148
|
+
LshIndex(20, 0)
|
|
149
|
+
idx = LshIndex(5, 2)
|
|
150
|
+
expected = r"^\[ERR_SIG_LEN\] signature length must be bands \* rows = 10$"
|
|
151
|
+
with pytest.raises(ValueError, match=expected):
|
|
152
|
+
idx.insert("d", [1, 2, 3])
|
|
153
|
+
with pytest.raises(ValueError, match=r"^\[ERR_SIG_LEN\]"):
|
|
154
|
+
idx.query([1])
|
|
155
|
+
|
|
156
|
+
def test_sorted_query_and_remove(self) -> None:
|
|
157
|
+
idx = LshIndex(3, 1)
|
|
158
|
+
sig = [1, 2, 3]
|
|
159
|
+
for doc in ("b_doc", "a_doc", "c_doc", "a_doc"):
|
|
160
|
+
idx.insert(doc, sig)
|
|
161
|
+
assert idx.query(sig) == ["a_doc", "b_doc", "c_doc"]
|
|
162
|
+
idx.remove("a_doc", sig)
|
|
163
|
+
assert idx.query(sig) == ["b_doc", "c_doc"]
|
|
164
|
+
|
|
165
|
+
def test_threshold(self) -> None:
|
|
166
|
+
assert abs(LshIndex(20, 5).threshold() - 0.5493) < 0.01
|
|
167
|
+
assert LshIndex(1, 1).threshold() == 1.0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestSha256State:
|
|
171
|
+
def test_streaming_equals_one_shot(self) -> None:
|
|
172
|
+
data = "Rust TypeScript Python Go PHP — satu kontrak bit-identical 🌍".encode()
|
|
173
|
+
for split in (0, 1, 31, 55, 56, 57, len(data)):
|
|
174
|
+
st = Sha256State()
|
|
175
|
+
st.update(data[:split])
|
|
176
|
+
st.update(data[split:])
|
|
177
|
+
assert st.hexdigest() == sha256_fn(data), f"split {split}"
|
|
178
|
+
|
|
179
|
+
def test_digest_non_destructive(self) -> None:
|
|
180
|
+
st = Sha256State()
|
|
181
|
+
st.update(b"hello")
|
|
182
|
+
first = st.digest()
|
|
183
|
+
assert st.digest() == first # digest() does not destroy state
|
|
184
|
+
st.update(b" world")
|
|
185
|
+
assert st.hexdigest() == sha256_fn(b"hello world")
|
|
186
|
+
|
|
187
|
+
def test_finalize_and_to_hex(self) -> None:
|
|
188
|
+
st = Sha256State()
|
|
189
|
+
st.update(b"abc")
|
|
190
|
+
digest = st.finalize()
|
|
191
|
+
assert to_hex(digest) == sha256_fn(b"abc")
|
|
192
|
+
assert len(digest) == 32
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TestAccessors:
|
|
196
|
+
def test_shingler_accessors(self) -> None:
|
|
197
|
+
s = Shingler("word", 4)
|
|
198
|
+
assert s.mode == "word"
|
|
199
|
+
assert s.n == 4
|
|
200
|
+
|
|
201
|
+
def test_lsh_accessors(self) -> None:
|
|
202
|
+
idx = LshIndex(7, 3)
|
|
203
|
+
assert idx.bands == 7
|
|
204
|
+
assert idx.rows == 3
|
|
205
|
+
assert idx.signature_len == 21
|
|
206
|
+
|
|
207
|
+
def test_to_hex64(self) -> None:
|
|
208
|
+
assert to_hex64(0) == "0" * 16
|
|
209
|
+
assert to_hex64(0xCBF29CE484222325) == "cbf29ce484222325"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestMinHashAccessors:
|
|
213
|
+
def test_num_perm_accessor(self) -> None:
|
|
214
|
+
a = MinHasher(42)
|
|
215
|
+
assert a.num_perm == 42
|
|
216
|
+
assert len(a.signature) == 42
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Vector tests — consume the ONE shared cross-language vectors file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from lomboksimhash import (
|
|
14
|
+
LshIndex,
|
|
15
|
+
MinHasher,
|
|
16
|
+
band_hash,
|
|
17
|
+
char_shingles,
|
|
18
|
+
fnv1a64,
|
|
19
|
+
jaccard_estimate,
|
|
20
|
+
sha256,
|
|
21
|
+
simhash64_text,
|
|
22
|
+
simhash128_text,
|
|
23
|
+
word_shingles,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
VECTORS = (
|
|
27
|
+
Path(__file__).resolve().parents[3] / "vectors" / "lomboksimhash-vectors-v2.json"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(scope="module")
|
|
32
|
+
def doc() -> dict[str, Any]:
|
|
33
|
+
with VECTORS.open(encoding="utf-8") as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
assert data["schema"] == "lomboksimhash-vectors-v2"
|
|
36
|
+
return data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hex64(v: int) -> str:
|
|
40
|
+
return f"{v:016x}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sig_of(hexes: list[str]) -> list[int]:
|
|
44
|
+
return [int(h, 16) for h in hexes]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_sha256_vectors(doc: dict[str, Any]) -> None:
|
|
48
|
+
for case in doc["cases"]["sha256"]:
|
|
49
|
+
data = case.get("input")
|
|
50
|
+
raw = data.encode("utf-8") if isinstance(data, str) else bytes.fromhex(case["input_hex"])
|
|
51
|
+
assert sha256(raw) == case["expected_hex"], f"case {case['id']}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_fnv_vectors(doc: dict[str, Any]) -> None:
|
|
55
|
+
for case in doc["cases"]["fnv1a64"]:
|
|
56
|
+
got = hex64(fnv1a64(bytes.fromhex(case["input_hex"])))
|
|
57
|
+
assert got == case["expected_hex"], f"case {case['id']}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_shingle_vectors(doc: dict[str, Any]) -> None:
|
|
61
|
+
for case in doc["cases"]["shingle"]:
|
|
62
|
+
fn = char_shingles if case["mode"] == "character" else word_shingles
|
|
63
|
+
got = fn(case["input"], case["n"])
|
|
64
|
+
assert got == case["expected"], f"case {case['id']}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_simhash64_vectors(doc: dict[str, Any]) -> None:
|
|
68
|
+
for case in doc["cases"]["simhash64"]:
|
|
69
|
+
got = hex64(simhash64_text(case["input"]))
|
|
70
|
+
assert got == case["expected_hex"], f"case {case['id']}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_simhash128_vectors(doc: dict[str, Any]) -> None:
|
|
74
|
+
for case in doc["cases"]["simhash128"]:
|
|
75
|
+
hi, lo = simhash128_text(case["input"])
|
|
76
|
+
assert hex64(hi) == case["hi_hex"], f"case {case['id']} hi"
|
|
77
|
+
assert hex64(lo) == case["lo_hex"], f"case {case['id']} lo"
|
|
78
|
+
assert hex64(hi) + hex64(lo) == case["hex"], f"case {case['id']} hex"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_minhash_signature_vectors(doc: dict[str, Any]) -> None:
|
|
82
|
+
for case in doc["cases"]["minhash_signature"]:
|
|
83
|
+
mh = MinHasher(case["num_perm"])
|
|
84
|
+
mh.add_tokens(case["tokens"])
|
|
85
|
+
got = [hex64(v) for v in mh.signature]
|
|
86
|
+
assert got == case["signature_hex"], f"case {case['id']}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_minhash_jaccard_vectors(doc: dict[str, Any]) -> None:
|
|
90
|
+
for case in doc["cases"]["minhash_jaccard"]:
|
|
91
|
+
num_perm = case["num_perm"]
|
|
92
|
+
a = MinHasher(num_perm)
|
|
93
|
+
b = MinHasher(num_perm)
|
|
94
|
+
a.add_tokens(case["set_a"])
|
|
95
|
+
b.add_tokens(case["set_b"])
|
|
96
|
+
matches = sum(1 for x, y in zip(a.signature, b.signature, strict=False) if x == y)
|
|
97
|
+
assert matches == case["expected_matches"], f"case {case['id']}"
|
|
98
|
+
assert jaccard_estimate(a.signature, b.signature) == matches / num_perm
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_lsh_bandhash_vectors(doc: dict[str, Any]) -> None:
|
|
102
|
+
for case in doc["cases"]["lsh_bandhash"]:
|
|
103
|
+
values = sig_of(case["band_values_hex"])
|
|
104
|
+
got = hex64(band_hash(values))
|
|
105
|
+
assert got == case["expected_key_hex"], f"case {case['id']}"
|
|
106
|
+
idx = LshIndex(1, len(values))
|
|
107
|
+
idx.insert("probe", values)
|
|
108
|
+
assert "probe" in idx.query(values)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_lsh_index_vectors(doc: dict[str, Any]) -> None:
|
|
112
|
+
for case in doc["cases"]["lsh_index"]:
|
|
113
|
+
idx = LshIndex(case["bands"], case["rows"])
|
|
114
|
+
for ins in case["inserts"]:
|
|
115
|
+
idx.insert(ins["doc_id"], sig_of(ins["signature_hex"]))
|
|
116
|
+
got = idx.query(sig_of(case["query_hex"]))
|
|
117
|
+
assert got == case["expected"], f"case {case['id']}"
|
|
118
|
+
remove = case.get("remove")
|
|
119
|
+
if remove:
|
|
120
|
+
idx.remove(remove["doc_id"], sig_of(remove["signature_hex"]))
|
|
121
|
+
after = idx.query(sig_of(case["query_hex"]))
|
|
122
|
+
assert after == case["expected_after_remove"], f"case {case['id']}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_vector_file_deterministic(doc: dict[str, Any]) -> None:
|
|
126
|
+
# The committed file must equal a fresh regeneration (spec §7).
|
|
127
|
+
repo = Path(__file__).resolve().parents[3]
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
[sys.executable, str(repo / "vectors" / "generate_vectors.py"), "--check"],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
check=False,
|
|
133
|
+
)
|
|
134
|
+
assert result.returncode == 0, result.stdout + result.stderr
|