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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,6 @@
1
+
2
+ [dev]
3
+ pytest>=8.0
4
+ pytest-cov>=5.0
5
+ ruff>=0.8
6
+ mypy>=1.13
@@ -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