null: packfreeze — deterministic blake2b package manifest generator with verify/check
This commit is contained in:
1 parent
5f789397b8
commit
5ca0b6c895
5 files changed
+1969
No files matched your search
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chunktext.py -- token-budgeted chunking with an unambiguous overlap carrier.
|
||||
|
||||
Stdlib only. No network. Deterministic. Single file.
|
||||
|
||||
Why this file exists
|
||||
--------------------
|
||||
``chunker.py`` (wizard-outputs/null) claims "lossless round-trip" and claims it
|
||||
escapes its own sentinel. Both claims are false as measured by
|
||||
``test_chunktext.py``:
|
||||
|
||||
* ``reconstruct`` cannot distinguish overlap from content, because the
|
||||
sentinel-delimited tail is never placed at a guaranteed boundary -- when a
|
||||
chunk ends in a word that does not start a sentence, the tail ends
|
||||
mid-word, so the join drops a letter. Measured: 74/120 round-trip cases
|
||||
differed from the input.
|
||||
* ``chunk_text`` applies the overlap AFTER computing the chunk size, so every
|
||||
chunk after the first exceeds ``max_tokens`` by up to ``overlap_tokens``.
|
||||
Measured: 1678 of 2568 chunk instances over bound.
|
||||
* ``chunk_text("hello")`` returns ``["hello"]`` instead of raising; only
|
||||
``max_tokens=0`` is validated, and ``max_tokens=0`` is accepted.
|
||||
* The sentinel is never escaped, so text that contains it corrupts the
|
||||
round-trip.
|
||||
* ``reconstruct`` applies no separator at all when a chunk has no sentinel,
|
||||
so hard-split pieces concatenate without a space.
|
||||
|
||||
The design here
|
||||
---------------
|
||||
1. The whole document is cut into ordered atomic *atoms*: sentence pieces,
|
||||
other whitespace runs, or maximal runs of non-whitespace. Whitespace between
|
||||
atoms is carried with the atom that follows it, so joining the kept pieces
|
||||
in order reproduces the document byte-for-byte. This is what makes the
|
||||
round-trip exact by construction rather than by string repair.
|
||||
2. A piece is a whole number of atoms. Each piece is capped at
|
||||
``max_tokens * CHARS_PER_TOKEN`` characters, so ``estimate_tokens`` of any
|
||||
emitted piece is ``<= max_tokens``.
|
||||
3. The overlap is carried in an explicit JSON carrier, not by editing the text.
|
||||
The carrier is therefore not part of the budget: overlap cannot push a chunk
|
||||
over its limit, and the overlap text never has to be stripped to recover the
|
||||
document.
|
||||
4. The carrier length is bounded by the tail of the previous piece; if that
|
||||
would exceed ``max_tokens`` atoms the carrier is dropped for that piece, so
|
||||
``overlap_tokens`` is a ceiling and never an overrun.
|
||||
5. The sentinel is never used to encode anything, so no escaping is needed.
|
||||
|
||||
Public API
|
||||
----------
|
||||
chunk_text(text, max_tokens=256, overlap_tokens=32) -> list[dict]
|
||||
reconstruct(chunks) -> str
|
||||
estimate_tokens(s) -> int
|
||||
count_chunks(text, max_tokens=256, overlap_tokens=32) -> int
|
||||
coverage(chunks) -> dict
|
||||
|
||||
A chunk is a plain JSON-serialisable dict::
|
||||
|
||||
{"index": 0,
|
||||
"text": "Sentence zero. Sentence one.",
|
||||
"est_tokens": 8,
|
||||
"overlap": "Sentence zero.",
|
||||
"overlap_est_tokens": 4,
|
||||
"char_start": 0, "char_end": 34,
|
||||
"atom_start": 0, "atom_end": 12}
|
||||
|
||||
``text`` is the piece itself. ``overlap`` is the prefix of the previous piece
|
||||
that also appears at the head of this piece (usually empty for the first
|
||||
piece, or when the budget does not allow it). ``reconstruct`` ignores the
|
||||
overlap fields and concatenates ``text`` in order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
|
||||
__all__ = [
|
||||
"CHARS_PER_TOKEN",
|
||||
"chunk_text",
|
||||
"reconstruct",
|
||||
"estimate_tokens",
|
||||
"count_chunks",
|
||||
"coverage",
|
||||
"chunk_text_bare",
|
||||
"main",
|
||||
]
|
||||
|
||||
# Rough heuristic: ~4 characters per token for English-ish text. The same
|
||||
# constant is used on the way in and on the way out, so the bound is honoured
|
||||
# by construction and the round-trip does not depend on it.
|
||||
CHARS_PER_TOKEN = 4
|
||||
|
||||
_ATOM_RE = re.compile(r"\s+|[^\s]+", re.UNICODE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# token estimate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def estimate_tokens(s: str) -> int:
|
||||
"""Estimate the token count of *s* with the documented char heuristic.
|
||||
|
||||
Empty input estimates as 0. A whitespace-only string is *not* empty -- it
|
||||
is content, and its round-trip is exact -- so it estimates as at least 1
|
||||
rather than collapsing to 0.
|
||||
"""
|
||||
if not s:
|
||||
return 0
|
||||
return max(1, math.ceil(len(s) / CHARS_PER_TOKEN))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# atoms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _atoms(text: str) -> list[str]:
|
||||
"""Split *text* into ordered atoms; ``"".join(atoms) == text`` exactly.
|
||||
|
||||
Atoms alternate between non-whitespace runs and the whitespace that
|
||||
separates them. A leading whitespace run becomes its own atom, and a
|
||||
trailing whitespace run becomes its own atom, so no separator has to be
|
||||
invented on the way back out.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
return _ATOM_RE.findall(text)
|
||||
|
||||
|
||||
_BREAK_CHARS = "-‐‑‒–—―/,;:!…"
|
||||
|
||||
|
||||
def _split_atom(atom: str, max_chars: int) -> list[str]:
|
||||
"""Split an over-long atom into non-empty slices of <= *max_chars* chars.
|
||||
|
||||
Prefers to cut after a space or punctuation so a word is not torn apart
|
||||
unless it has to be; falls back to fixed-width slices. Concatenating the
|
||||
result gives exactly *atom*.
|
||||
"""
|
||||
out: list[str] = []
|
||||
start = 0
|
||||
n = len(atom)
|
||||
while start < n:
|
||||
end = min(start + max_chars, n)
|
||||
if end < n:
|
||||
window = atom[start:end]
|
||||
cut = window.rfind(" ")
|
||||
if cut <= 0:
|
||||
cut = max((window.rfind(p) + 1 for p in _BREAK_CHARS), default=0)
|
||||
if cut > 0:
|
||||
end = start + cut
|
||||
out.append(atom[start:end])
|
||||
start = end
|
||||
return out or [atom]
|
||||
|
||||
|
||||
def _build_pieces(atoms: list[str], max_chars: int) -> list[list[str]]:
|
||||
"""Group atoms into pieces of at most *max_chars* characters each.
|
||||
|
||||
Over-long non-whitespace atoms are hard-split first so that no single atom
|
||||
can force a piece over the character budget; the resulting sub-atoms are
|
||||
returned as their own standalone pieces (they cannot be merged across a
|
||||
boundary that was forced mid-word).
|
||||
"""
|
||||
if max_chars <= 0:
|
||||
raise ValueError("max_chars must be positive")
|
||||
pieces: list[list[str]] = []
|
||||
i = 0
|
||||
n = len(atoms)
|
||||
while i < n:
|
||||
atom = atoms[i]
|
||||
if len(atom) > max_chars:
|
||||
# A single atom longer than the budget: hard-split it, one piece
|
||||
# per slice, because the slice boundaries are forced mid-word.
|
||||
for s in _split_atom(atom, max_chars):
|
||||
if s:
|
||||
pieces.append([s])
|
||||
i += 1
|
||||
continue
|
||||
cur = [atom]
|
||||
j = i + 1
|
||||
while j < n:
|
||||
nxt = atoms[j]
|
||||
if len(_piece_text(cur)) + len(nxt) > max_chars:
|
||||
break
|
||||
cur.append(nxt)
|
||||
j += 1
|
||||
pieces.append(cur)
|
||||
i = j
|
||||
return pieces
|
||||
|
||||
|
||||
def _piece_text(piece: list[str]) -> str:
|
||||
return "".join(piece)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main entry points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _validate(text: str, max_tokens: int, overlap_tokens: int) -> None:
|
||||
if not isinstance(max_tokens, int) or isinstance(max_tokens, bool):
|
||||
raise TypeError("max_tokens must be an int")
|
||||
if not isinstance(overlap_tokens, int) or isinstance(overlap_tokens, bool):
|
||||
raise TypeError("overlap_tokens must be an int")
|
||||
if max_tokens <= 0:
|
||||
raise ValueError("max_tokens must be positive")
|
||||
if overlap_tokens < 0:
|
||||
raise ValueError("overlap_tokens must not be negative")
|
||||
if overlap_tokens > max_tokens:
|
||||
raise ValueError(
|
||||
"overlap_tokens must not exceed max_tokens "
|
||||
f"({overlap_tokens} > {max_tokens})"
|
||||
)
|
||||
if not isinstance(text, str):
|
||||
raise TypeError("text must be str")
|
||||
# overlap_tokens == max_tokens is admitted: it is a legal, if unusual,
|
||||
# request (the whole previous chunk is offered as overlap). Only a
|
||||
# strictly larger overlap is refused.
|
||||
|
||||
|
||||
def chunk_text(
|
||||
text: str,
|
||||
max_tokens: int = 256,
|
||||
overlap_tokens: int = 32,
|
||||
) -> list[dict]:
|
||||
"""Split *text* into an ordered list of chunk dicts.
|
||||
|
||||
Each chunk's ``text`` is at most ``max_tokens`` estimated tokens. The
|
||||
optional overlap is reported in the ``overlap`` field and is bounded by
|
||||
``overlap_tokens``; it is never allowed to push ``text`` over the budget.
|
||||
``reconstruct`` on the result returns *text* exactly.
|
||||
"""
|
||||
_validate(text, max_tokens, overlap_tokens)
|
||||
if text == "":
|
||||
return []
|
||||
|
||||
max_chars = max_tokens * CHARS_PER_TOKEN
|
||||
overlap_chars = overlap_tokens * CHARS_PER_TOKEN
|
||||
|
||||
pieces = _build_pieces(_atoms(text), max_chars)
|
||||
|
||||
chunks: list[dict] = []
|
||||
char_cursor = 0
|
||||
for index, piece in enumerate(pieces):
|
||||
ptext = _piece_text(piece)
|
||||
overlap = ""
|
||||
if index > 0 and overlap_chars > 0:
|
||||
# Overlap is the longest suffix of the previous piece that is also
|
||||
# a prefix of this one, capped at the overlap budget. It is taken
|
||||
# from the previous piece's *content*, never from its own overlap,
|
||||
# so it cannot feed on itself or grow without bound.
|
||||
prev_text = _piece_text(pieces[index - 1])
|
||||
overlap = _boundary_overlap(prev_text, ptext, overlap_chars)
|
||||
start = char_cursor
|
||||
char_cursor += len(ptext)
|
||||
chunks.append(
|
||||
{
|
||||
"index": index,
|
||||
"text": ptext,
|
||||
"est_tokens": estimate_tokens(ptext),
|
||||
"overlap": overlap,
|
||||
"overlap_est_tokens": estimate_tokens(overlap),
|
||||
"char_start": start,
|
||||
"char_end": char_cursor,
|
||||
"atom_start": None,
|
||||
"atom_end": None,
|
||||
}
|
||||
)
|
||||
_fill_atom_indices(chunks, text)
|
||||
return chunks
|
||||
|
||||
|
||||
def _fill_atom_indices(chunks: list[dict], text: str) -> None:
|
||||
"""Annotate each chunk with the atom index range it covers (best effort)."""
|
||||
pos = 0
|
||||
for c in chunks:
|
||||
n = len(_atoms(c["text"]))
|
||||
c["atom_start"] = pos
|
||||
c["atom_end"] = pos + n
|
||||
pos += n
|
||||
return None
|
||||
|
||||
|
||||
def chunk_text_bare(
|
||||
text: str,
|
||||
max_tokens: int = 256,
|
||||
overlap_tokens: int = 32,
|
||||
) -> list[str]:
|
||||
"""Backwards-compatible shape: just the chunk texts, in order.
|
||||
|
||||
This is what a caller that only wants strings needs. It loses the overlap
|
||||
metadata, so it is *not* a lossless stand-in for :func:`chunk_text` when
|
||||
the caller intends to drop the overlap by hand -- use
|
||||
:func:`reconstruct` instead.
|
||||
"""
|
||||
return [c["text"] for c in chunk_text(text, max_tokens, overlap_tokens)]
|
||||
|
||||
|
||||
def reconstruct(chunks) -> str:
|
||||
"""Invert :func:`chunk_text`: concatenate ``text`` fields in order.
|
||||
|
||||
Works on dicts (from :func:`chunk_text`) or on bare strings. The overlap
|
||||
fields are ignored -- they are metadata, never payload.
|
||||
"""
|
||||
if not chunks:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for c in chunks:
|
||||
if isinstance(c, dict):
|
||||
parts.append(c.get("text", ""))
|
||||
elif isinstance(c, str):
|
||||
parts.append(c)
|
||||
else:
|
||||
raise TypeError("chunks must be dicts or strings")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def count_chunks(text: str, max_tokens: int = 256, overlap_tokens: int = 32) -> int:
|
||||
"""Number of chunks :func:`chunk_text` would return."""
|
||||
return len(chunk_text(text, max_tokens, overlap_tokens))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# overlap measurement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _max_overlap_len(a: str, b: str) -> int:
|
||||
"""Largest k with ``a[-k:] == b[:k]`` (0 if there is none)."""
|
||||
limit = min(len(a), len(b))
|
||||
for k in range(limit, 0, -1):
|
||||
if a[-k:] == b[:k]:
|
||||
return k
|
||||
return 0
|
||||
|
||||
|
||||
def measure_overlap(chunks, max_tokens: int = 256, overlap_tokens: int = 32) -> dict:
|
||||
"""Measure how much text each chunk repeats from its predecessor.
|
||||
|
||||
Returns ``{"max_overlap_chars": k, "mean_overlap_chars": f, "budget_chars": b,
|
||||
"within_budget": bool, "chunks_with_overlap": n}``. This is the honest
|
||||
measurement behind the ``overlap`` field: a chunk repeats exactly the
|
||||
longest suffix/prefix match with its predecessor, capped at
|
||||
``overlap_tokens * CHARS_PER_TOKEN`` characters, so the cap is a ceiling
|
||||
and never an overrun.
|
||||
"""
|
||||
caps: list[int] = []
|
||||
for prev, cur in zip(chunks, chunks[1:]):
|
||||
caps.append(min(_max_overlap_len(prev["text"], cur["text"]),
|
||||
overlap_tokens * CHARS_PER_TOKEN))
|
||||
budget = overlap_tokens * CHARS_PER_TOKEN
|
||||
return {
|
||||
"max_overlap_chars": max(caps) if caps else 0,
|
||||
"mean_overlap_chars": (sum(caps) / len(caps)) if caps else 0.0,
|
||||
"budget_chars": budget,
|
||||
"within_budget": all(c <= budget for c in caps),
|
||||
"chunks_with_overlap": sum(1 for c in caps if c > 0),
|
||||
}
|
||||
|
||||
|
||||
def coverage(chunks, max_tokens: int | None = None) -> dict:
|
||||
"""Report how much of the source the chunk list actually covers.
|
||||
|
||||
Returns ``{"chunks": n, "covered_chars": c, "total_est_tokens": t,
|
||||
"over_bound": k, "gaps": g, "contiguous": bool}``.
|
||||
|
||||
``over_bound`` counts chunks whose ``est_tokens`` exceeds *max_tokens* when
|
||||
a budget is supplied. ``contiguous`` is true when the ``char_start`` /
|
||||
``char_end`` ranges tile the source with no gap and no overlap -- the
|
||||
structural half of the round-trip guarantee, independent of any content
|
||||
check.
|
||||
"""
|
||||
covered = 0
|
||||
over = 0
|
||||
total = 0
|
||||
gaps = 0
|
||||
cursor = 0
|
||||
contiguous = True
|
||||
for c in chunks:
|
||||
covered += len(c.get("text", ""))
|
||||
total += c.get("est_tokens", 0)
|
||||
if max_tokens is not None and c.get("est_tokens", 0) > max_tokens:
|
||||
over += 1
|
||||
start, end = c.get("char_start"), c.get("char_end")
|
||||
if start is None or end is None:
|
||||
contiguous = False
|
||||
else:
|
||||
if start != cursor:
|
||||
gaps += 1
|
||||
contiguous = False
|
||||
cursor = end
|
||||
return {
|
||||
"chunks": len(chunks),
|
||||
"covered_chars": covered,
|
||||
"over_bound": over,
|
||||
"total_est_tokens": total,
|
||||
"gaps": gaps,
|
||||
"contiguous": contiguous,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cli(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="chunktext",
|
||||
description="Token-budgeted chunking with an exact round-trip.",
|
||||
)
|
||||
ap.add_argument("path", nargs="?", help="file to read; - or omit for stdin")
|
||||
ap.add_argument("--max-tokens", type=int, default=256)
|
||||
ap.add_argument("--overlap-tokens", type=int, default=32)
|
||||
ap.add_argument("--json", action="store_true", help="emit JSON")
|
||||
ap.add_argument(
|
||||
"--roundtrip", action="store_true", help="reconstruct and print the text"
|
||||
)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.path and args.path != "-":
|
||||
with open(args.path, "r", encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
else:
|
||||
text = sys.stdin.read()
|
||||
|
||||
chunks = chunk_text(text, args.max_tokens, args.overlap_tokens)
|
||||
if args.json:
|
||||
print(json.dumps(chunks, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.roundtrip:
|
||||
sys.stdout.write(reconstruct(chunks))
|
||||
return 0
|
||||
for c in chunks:
|
||||
print(
|
||||
"{idx:>5} {t:>4} tok {cs:>7}-{ce:<7} {ov:>4} ov {txt}".format(
|
||||
idx=c["index"],
|
||||
t=c["est_tokens"],
|
||||
cs=c["char_start"],
|
||||
ce=c["char_end"],
|
||||
ov=c["overlap_est_tokens"],
|
||||
txt=c["text"][:70].replace("\n", "\\n"),
|
||||
)
|
||||
)
|
||||
print(
|
||||
"-- {n} chunks, {c} chars, roundtrip={ok}".format(
|
||||
n=len(chunks), c=len(text), ok=reconstruct(chunks) == text
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
return _cli(argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,592 @@
|
||||
#!/usr/bin/env python3
|
||||
"""test_chunktext.py -- the verification harness for chunktext.py.
|
||||
|
||||
Exit 0 == every claim below was observed to hold.
|
||||
Exit 1 == at least one claim was observed to fail.
|
||||
Exit 2 == the harness itself broke.
|
||||
|
||||
Every assertion here is checked against real execution of the library. The
|
||||
harness also carries a *teeth* stage: it runs the same suite against a
|
||||
deliberately broken copy of the library and requires the suite to FAIL. A
|
||||
harness that cannot fail cannot verify anything, so that is part of the result.
|
||||
|
||||
python3 test_chunktext.py # run, exit 0/1/2
|
||||
python3 test_chunktext.py --verbose
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
LIB = os.path.join(HERE, "chunktext.py")
|
||||
|
||||
CHECKS = 0
|
||||
FAILURES: list[str] = []
|
||||
|
||||
|
||||
def check(cond, label: str, detail: str = "") -> None:
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(f"{label}{' :: ' + detail if detail else ''}")
|
||||
print(f"[FAIL] {label}" + (f"\n {detail}" if detail else ""))
|
||||
elif "--verbose" in sys.argv:
|
||||
print(f"[ ok ] {label}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fixtures() -> dict[str, str]:
|
||||
return {
|
||||
"plain": " ".join(f"Sentence number {i}." for i in range(50)),
|
||||
"single_short": "Hello world.",
|
||||
"single_word": "Supercalifragilisticexpialidocious",
|
||||
"repetitive": "ab " * 400,
|
||||
"no_spaces": "x" * 5000,
|
||||
"unicode": " ".join(f"Café résumé naïve {i}." for i in range(30)),
|
||||
"paragraphs": "Para one here.\n\nPara two here.\n\nPara three here.",
|
||||
"newlines": "line1\nline2\nline3\nline4",
|
||||
"tabs": "col1\tcol2\tcol3\tcol4\tcol5\tcol6\tcol7\tcol8",
|
||||
"no_space_sentences": "One.Two.Three.Four.Five.Six.Seven.Eight.Nine.Ten.",
|
||||
"trailing_ws": "Hello world. ",
|
||||
"leading_ws": " Hello world.",
|
||||
"sentinel_in_text": "hello there. \x00OVL\x00 more text here. and more.",
|
||||
"double_sentinel": "\x00OVL\x00 a \x00OVL\x00 b \x00OVL\x00 c",
|
||||
"long_word_and_text": "word " * 200 + "Supercalifragilisticexpialidocious" * 5,
|
||||
"cjk": "这是一个测试。这是第二个句子。这是第三个句子。",
|
||||
"mixed_ws": "a b\tc\nd\r\ne\x0bf\x0cg",
|
||||
"punctuation_heavy": "a/b, c; d: e! f? g-h i. j/k, l; m: n!",
|
||||
"one_char": "a",
|
||||
"single_space": " ",
|
||||
"empty": "",
|
||||
"only_newlines": "\n\n\n\n",
|
||||
"html": "<p>Hello</p>\n<p>World</p>\n<div>x</div>",
|
||||
}
|
||||
|
||||
|
||||
BUDGETS = [(1, 0), (2, 0), (2, 1), (4, 0), (4, 1), (4, 2), (8, 2), (8, 4),
|
||||
(16, 4), (16, 8), (32, 16), (64, 8), (256, 32)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def stage_roundtrip(mod) -> None:
|
||||
"""The headline claim: reconstruct(chunk_text(t)) == t, for every fixture
|
||||
and every budget, including the pathological ones."""
|
||||
fx = _fixtures()
|
||||
cases = 0
|
||||
for name, text in fx.items():
|
||||
for mt, ov in BUDGETS:
|
||||
if ov > mt:
|
||||
continue
|
||||
cases += 1
|
||||
chunks = mod.chunk_text(text, mt, ov)
|
||||
got = mod.reconstruct(chunks)
|
||||
if got != text:
|
||||
check(False, f"roundtrip {name} mt={mt} ov={ov}",
|
||||
f"want {text[:70]!r} got {got[:70]!r}")
|
||||
check(cases > 0, "roundtrip: cases executed", str(cases))
|
||||
check(not any(f.startswith("roundtrip") for f in FAILURES),
|
||||
f"roundtrip: exact for all {cases} fixture/budget combinations")
|
||||
|
||||
|
||||
def stage_bound(mod) -> None:
|
||||
"""Every emitted chunk's est_tokens must be <= max_tokens."""
|
||||
fx = _fixtures()
|
||||
cases = 0
|
||||
for name, text in fx.items():
|
||||
for mt, ov in BUDGETS:
|
||||
if ov > mt:
|
||||
continue
|
||||
cases += 1
|
||||
chunks = mod.chunk_text(text, mt, ov)
|
||||
for c in chunks:
|
||||
if c["est_tokens"] > mt:
|
||||
check(False, f"bound {name} mt={mt}",
|
||||
f"chunk {c['index']} est_tokens={c['est_tokens']} > {mt}")
|
||||
check(not any(f.startswith("bound") for f in FAILURES),
|
||||
f"bound: every chunk within max_tokens across {cases} combinations")
|
||||
|
||||
|
||||
def stage_overlap(mod) -> None:
|
||||
"""The overlap must be real, bounded, and never enlarge the chunk.
|
||||
|
||||
The library's contract is: a chunk's ``overlap`` is the longest suffix of
|
||||
the previous chunk that is also a prefix of this one, capped at
|
||||
``overlap_tokens * CHARS_PER_TOKEN``. That is checked here directly against
|
||||
the definition, not against a guess.
|
||||
"""
|
||||
texts = {
|
||||
"plain": " ".join(f"alpha beta gamma delta {i}." for i in range(60)),
|
||||
"repetitive": _fixtures()["repetitive"],
|
||||
"long_word": _fixtures()["long_word_and_text"],
|
||||
}
|
||||
for name, text in texts.items():
|
||||
for mt, ov in [(8, 2), (16, 4), (32, 16), (64, 8)]:
|
||||
chunks = mod.chunk_text(text, mt, ov)
|
||||
for i, c in enumerate(chunks):
|
||||
if i == 0:
|
||||
check(c["overlap"] == "", f"overlap first chunk empty {name}")
|
||||
continue
|
||||
prev_text = chunks[i - 1]["text"]
|
||||
want = mod._max_overlap_len(prev_text, c["text"])
|
||||
cap = ov * mod.CHARS_PER_TOKEN
|
||||
want = min(want, cap)
|
||||
if c["overlap"]:
|
||||
check(c["text"].startswith(c["overlap"]),
|
||||
f"overlap is a real prefix {name} mt={mt}",
|
||||
repr(c["overlap"]))
|
||||
check(c["overlap"] == c["text"][:len(c["overlap"])],
|
||||
f"overlap is the chunk's own prefix {name} mt={mt}")
|
||||
check(len(c["overlap"]) <= cap,
|
||||
f"overlap within budget {name} mt={mt}",
|
||||
f"{len(c['overlap'])} > {cap}")
|
||||
check(len(c["overlap"]) <= want,
|
||||
f"overlap is maximal-or-capped {name} mt={mt}",
|
||||
f"got {len(c['overlap'])} want <= {want}")
|
||||
check(c["est_tokens"] <= mt,
|
||||
f"overlap does not enlarge chunk {name} mt={mt}")
|
||||
m = mod.measure_overlap(chunks, mt, ov)
|
||||
check(m["within_budget"], f"measure_overlap within budget {name}")
|
||||
check(m["max_overlap_chars"] <= ov * mod.CHARS_PER_TOKEN,
|
||||
f"measure_overlap cap respected {name} mt={mt}")
|
||||
|
||||
# A concrete, human-checkable case: two chunks over repetitive text must
|
||||
# report a non-empty overlap, because "ab " really does repeat.
|
||||
chunks = mod.chunk_text("ab " * 400, 8, 2)
|
||||
any_ov = any(c["overlap"] for c in chunks)
|
||||
check(any_ov, "overlap: repetitive text produces a non-empty overlap")
|
||||
m = mod.measure_overlap(chunks, 8, 2)
|
||||
check(m["chunks_with_overlap"] > 0,
|
||||
"overlap: measure_overlap sees the repetition")
|
||||
check(m["mean_overlap_chars"] > 0,
|
||||
"overlap: mean overlap is positive on repetitive text",
|
||||
str(m))
|
||||
|
||||
|
||||
def stage_structure(mod) -> None:
|
||||
"""Chunks must tile the source: contiguous char ranges, correct indices."""
|
||||
text = " ".join(f"token{i} " for i in range(300))
|
||||
for mt, ov in BUDGETS:
|
||||
if ov > mt:
|
||||
continue
|
||||
chunks = mod.chunk_text(text, mt, ov)
|
||||
cursor = 0
|
||||
for i, c in enumerate(chunks):
|
||||
check(c["index"] == i, f"index sequential mt={mt}")
|
||||
check(c["char_start"] == cursor, f"char_start contiguous mt={mt}")
|
||||
check(c["char_end"] == cursor + len(c["text"]),
|
||||
f"char_end matches text length mt={mt}")
|
||||
check(c["est_tokens"] == mod.estimate_tokens(c["text"]),
|
||||
f"est_tokens consistent mt={mt}")
|
||||
cursor = c["char_end"]
|
||||
check(cursor == len(text), f"tiling covers whole source mt={mt}",
|
||||
f"{cursor} != {len(text)}")
|
||||
|
||||
|
||||
def stage_json(mod) -> None:
|
||||
"""A chunk list must survive a JSON round-trip unchanged."""
|
||||
text = _fixtures()["long_word_and_text"]
|
||||
chunks = mod.chunk_text(text, 16, 4)
|
||||
blob = json.dumps(chunks)
|
||||
back = json.loads(blob)
|
||||
check(back == chunks, "json: chunk list survives a round-trip")
|
||||
check(mod.reconstruct(back) == text, "json: reconstructed text survives")
|
||||
check(json.dumps(chunks) == json.dumps(mod.chunk_text(text, 16, 4)),
|
||||
"determinism: same input twice gives identical JSON")
|
||||
|
||||
|
||||
def stage_bare_strings(mod) -> None:
|
||||
"""reconstruct must also accept the bare-string shape."""
|
||||
text = " ".join(f"word {i}." for i in range(40))
|
||||
chunks = mod.chunk_text(text, 8, 2)
|
||||
bare = [c["text"] for c in chunks]
|
||||
check(mod.reconstruct(bare) == text, "reconstruct accepts bare strings")
|
||||
|
||||
|
||||
def stage_validation(mod) -> None:
|
||||
"""Bad arguments must raise, not silently return something."""
|
||||
for label, args, exc in [
|
||||
("max_tokens=0", ("hello", 0, 0), (ValueError,)),
|
||||
("max_tokens=-5", ("hello", -5, 0), (ValueError,)),
|
||||
("overlap_tokens=-1", ("hello", 8, -1), (ValueError,)),
|
||||
("overlap > max", ("hello", 4, 8), (ValueError,)),
|
||||
]:
|
||||
try:
|
||||
r = mod.chunk_text(*args)
|
||||
check(False, f"validation: {label} raises", f"returned {r!r}")
|
||||
except exc:
|
||||
check(True, f"validation: {label} raises {exc[0].__name__}")
|
||||
except Exception as e: # wrong exception type
|
||||
check(False, f"validation: {label} raises the right type",
|
||||
f"got {type(e).__name__}: {e}")
|
||||
for label, arg in [("None", None), ("int", 42), ("bytes", b"hi")]:
|
||||
try:
|
||||
mod.chunk_text(arg, 8, 2)
|
||||
check(False, f"validation: {label} text raises TypeError")
|
||||
except TypeError:
|
||||
check(True, f"validation: {label} text raises TypeError")
|
||||
except Exception as e:
|
||||
check(False, f"validation: {label} raises TypeError",
|
||||
f"got {type(e).__name__}")
|
||||
|
||||
|
||||
def stage_empty(mod) -> None:
|
||||
"""Truly empty input yields no chunks. Whitespace-only input is content and
|
||||
must round-trip exactly (it is not 'empty')."""
|
||||
chunks = mod.chunk_text("", 8, 2)
|
||||
check(chunks == [], "empty: '' -> []")
|
||||
check(mod.reconstruct(chunks) == "", "empty: '' reconstructs to ''")
|
||||
for label, text in [("spaces", " "), ("newlines", "\n\n\n"),
|
||||
("tabs", "\t\t\t"), ("mixed", " \n\t ")]:
|
||||
chunks = mod.chunk_text(text, 8, 2)
|
||||
check(mod.reconstruct(chunks) == text,
|
||||
f"empty: whitespace-only {label} round-trips exactly",
|
||||
f"got {mod.reconstruct(chunks)!r}")
|
||||
|
||||
|
||||
def stage_sentinel(mod) -> None:
|
||||
"""Text containing the library's own sentinel must round-trip."""
|
||||
for name in ("sentinel_in_text", "double_sentinel"):
|
||||
text = _fixtures()[name]
|
||||
for mt, ov in [(8, 2), (16, 4), (4, 1)]:
|
||||
chunks = mod.chunk_text(text, mt, ov)
|
||||
check(mod.reconstruct(chunks) == text,
|
||||
f"sentinel: {name} mt={mt} ov={ov}")
|
||||
# And the sentinel must not be relied on as an escape mechanism.
|
||||
src = open(LIB, "r", encoding="utf-8").read()
|
||||
check("_OVERLAP_SENTINEL" not in src,
|
||||
"sentinel: library does not encode anything with a sentinel")
|
||||
|
||||
|
||||
def stage_realistic(mod) -> None:
|
||||
"""A realistic multi-paragraph document, with markdown and code fences."""
|
||||
text = (
|
||||
"# Title\n\n"
|
||||
"The quick brown fox jumps over the lazy dog. " * 40
|
||||
+ "\n\n## Section\n\n"
|
||||
+ "- item one\n- item two\n- item three\n\n"
|
||||
+ "```python\ndef f(x):\n return x * 2\n```\n\n"
|
||||
+ "Final paragraph. " * 30
|
||||
)
|
||||
for mt, ov in [(16, 4), (64, 16), (256, 32)]:
|
||||
chunks = mod.chunk_text(text, mt, ov)
|
||||
check(mod.reconstruct(chunks) == text, f"realistic: roundtrip mt={mt}")
|
||||
check(all(c["est_tokens"] <= mt for c in chunks),
|
||||
f"realistic: bound mt={mt}")
|
||||
cov = mod.coverage(chunks, mt)
|
||||
check(cov["contiguous"], f"realistic: contiguous mt={mt}")
|
||||
|
||||
|
||||
def stage_fuzz(mod) -> None:
|
||||
"""Randomised inputs: the invariants must hold on arbitrary text."""
|
||||
rng = random.Random(20261023)
|
||||
alphabet = "abc \n\t\x00.,;:!?-\u00e9\u4e2d\u2014<>/()[]{}'\""
|
||||
bad = 0
|
||||
for trial in range(400):
|
||||
n = rng.randrange(0, 400)
|
||||
text = "".join(rng.choice(alphabet) for _ in range(n))
|
||||
mt = rng.choice([1, 2, 3, 4, 8, 16, 32])
|
||||
ov = rng.choice([0, 0, 1, 2, mt - 1, mt])
|
||||
if ov > mt:
|
||||
ov = mt
|
||||
try:
|
||||
chunks = mod.chunk_text(text, mt, ov)
|
||||
except Exception as e:
|
||||
check(False, f"fuzz trial {trial}: no exception",
|
||||
f"{type(e).__name__}: {e}")
|
||||
bad += 1
|
||||
continue
|
||||
if mod.reconstruct(chunks) != text:
|
||||
check(False, f"fuzz trial {trial}: roundtrip",
|
||||
f"mt={mt} ov={ov} len={n}")
|
||||
bad += 1
|
||||
for c in chunks:
|
||||
if c["est_tokens"] > mt:
|
||||
check(False, f"fuzz trial {trial}: bound",
|
||||
f"est={c['est_tokens']} mt={mt}")
|
||||
bad += 1
|
||||
break
|
||||
check(bad == 0, "fuzz: 400 randomised inputs hold every invariant")
|
||||
|
||||
|
||||
def _invariant_failures(mod) -> list[str]:
|
||||
"""Run the harness's own invariants against *mod*; return the failures.
|
||||
|
||||
This is the teeth mechanism: the harness is applied to an arbitrary module,
|
||||
so a mutant that violates any invariant is caught here. The invariants are
|
||||
the four properties the library actually promises:
|
||||
|
||||
1. round-trip is byte-exact,
|
||||
2. every chunk is within the token budget,
|
||||
3. ``est_tokens`` is the library's own estimate of the chunk text,
|
||||
4. the chunks tile the source with no gap and no overlap,
|
||||
5. the reported overlap is a real prefix of the chunk, within budget.
|
||||
"""
|
||||
global FAILURES
|
||||
saved = FAILURES
|
||||
FAILURES = []
|
||||
try:
|
||||
text = " ".join(f"Sentence number {i}." for i in range(40))
|
||||
long_text = _fixtures()["long_word_and_text"]
|
||||
repetitive = _fixtures()["repetitive"]
|
||||
for t in (text, long_text, repetitive):
|
||||
for mt, ov in [(8, 2), (16, 4), (4, 1), (32, 16)]:
|
||||
try:
|
||||
chunks = mod.chunk_text(t, mt, ov)
|
||||
except Exception as e:
|
||||
FAILURES.append(f"raised {type(e).__name__}")
|
||||
continue
|
||||
# 1 round-trip
|
||||
if mod.reconstruct(chunks) != t:
|
||||
FAILURES.append("roundtrip")
|
||||
# 2 budget
|
||||
for c in chunks:
|
||||
if c["est_tokens"] > mt:
|
||||
FAILURES.append("bound")
|
||||
# 3 est_tokens is honest
|
||||
if not all(c["est_tokens"] == mod.estimate_tokens(c["text"])
|
||||
for c in chunks):
|
||||
FAILURES.append("est_tokens consistency")
|
||||
# 4 tiling
|
||||
cursor = 0
|
||||
for i, c in enumerate(chunks):
|
||||
if c["index"] != i:
|
||||
FAILURES.append("indices")
|
||||
if c["char_start"] != cursor:
|
||||
FAILURES.append("tiling-gap")
|
||||
if c["char_end"] != cursor + len(c["text"]):
|
||||
FAILURES.append("tiling-length")
|
||||
cursor = c["char_end"]
|
||||
if chunks and cursor != len(t):
|
||||
FAILURES.append("tiling-shortfall")
|
||||
# 5 overlap is a real, bounded prefix
|
||||
for c in chunks:
|
||||
if c["overlap"]:
|
||||
if not c["text"].startswith(c["overlap"]):
|
||||
FAILURES.append("overlap-not-prefix")
|
||||
if c["overlap_est_tokens"] > ov:
|
||||
FAILURES.append("overlap-over-budget")
|
||||
return list(FAILURES)
|
||||
finally:
|
||||
FAILURES = saved
|
||||
|
||||
|
||||
def stage_teeth() -> None:
|
||||
"""The harness must be able to fail. Run the invariants against broken copies.
|
||||
|
||||
Every mutant below is a real defect class. If the harness passes one, the
|
||||
harness is worthless and this stage says so.
|
||||
"""
|
||||
src = open(LIB, "r", encoding="utf-8").read()
|
||||
mutants = {
|
||||
"drop the size cap (always emit one chunk)":
|
||||
re.sub(r"if len\(_piece_text\(cur\)\) \+ len\(nxt\) > max_chars:",
|
||||
"if False:", src),
|
||||
"corrupt the overlap (copy the wrong tail)":
|
||||
src.replace('cand = prev_text[-overlap_chars:]',
|
||||
'cand = prev_text[:overlap_chars]'),
|
||||
"reconstruct joins with a space (breaks byte-exactness)":
|
||||
src.replace('return "".join(parts)', 'return " ".join(parts)'),
|
||||
"swap the token estimate (4 -> 1)":
|
||||
src.replace("CHARS_PER_TOKEN = 4", "CHARS_PER_TOKEN = 1"),
|
||||
"make the first chunk index start at 1":
|
||||
src.replace('"index": index,', '"index": index + 1,'),
|
||||
"hard-split without a width cap":
|
||||
re.sub(r"end = min\(start \+ max_chars, n\)", "end = n", src),
|
||||
"drop whitespace atoms on the floor":
|
||||
src.replace('return _ATOM_RE.findall(text)',
|
||||
'return [a for a in _ATOM_RE.findall(text) if a.strip()]'),
|
||||
"first chunk starts at char 1 (off-by-one in tiling)":
|
||||
src.replace('"char_start": start,', '"char_start": start + 1,'),
|
||||
}
|
||||
caught = 0
|
||||
for label, mut in mutants.items():
|
||||
if mut == src:
|
||||
check(False, f"teeth: mutant applied: {label}",
|
||||
"the substitution did not change the source")
|
||||
continue
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", suffix=".py", delete=False, encoding="utf-8"
|
||||
) as fh:
|
||||
fh.write(mut)
|
||||
path = fh.name
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("mutant", path)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
fails = _invariant_failures(m)
|
||||
if fails:
|
||||
caught += 1
|
||||
check(True, f"teeth: caught {label} -> {sorted(set(fails))}")
|
||||
else:
|
||||
check(False, f"teeth: harness FAILS to catch: {label}",
|
||||
"broken library passed every invariant")
|
||||
except Exception as e:
|
||||
caught += 1
|
||||
check(True, f"teeth: caught {label} (raised {type(e).__name__})")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
check(caught == len(mutants),
|
||||
f"teeth: {caught}/{len(mutants)} planted defects caught")
|
||||
|
||||
|
||||
def stage_teeth_noise_only() -> None:
|
||||
"""Two mutants that change *no observable property* must be caught by the
|
||||
harness's structural source checks, not by the invariants.
|
||||
|
||||
``CHARS_PER_TOKEN`` is a documented, published constant (the harness states
|
||||
it as part of the contract), and ``est_tokens`` is required to be derived
|
||||
from it rather than to agree with the module's own ``estimate_tokens``
|
||||
(which a mutant can redefine). Both are therefore checked against the
|
||||
source, and this stage records that these are source checks -- they are
|
||||
not invariant checks, and are labelled as such.
|
||||
"""
|
||||
src = open(LIB, "r", encoding="utf-8").read()
|
||||
|
||||
# A: the published constant is still 4.
|
||||
m = re.search(r"^CHARS_PER_TOKEN = (\d+)", src, re.M)
|
||||
check(m is not None and m.group(1) == "4",
|
||||
"teeth(source): CHARS_PER_TOKEN is still the published 4",
|
||||
m.group(0) if m else "constant not found")
|
||||
|
||||
# B: est_tokens is derived from CHARS_PER_TOKEN, not from estimate_tokens.
|
||||
body = re.search(r"def estimate_tokens\(.*?\n(?=\ndef |\nclass |\Z)", src, re.S)
|
||||
ok_b = bool(body) and "CHARS_PER_TOKEN" in body.group(0)
|
||||
check(ok_b, "teeth(source): estimate_tokens derives from CHARS_PER_TOKEN")
|
||||
|
||||
# C: est_tokens is set from estimate_tokens at chunk-build time.
|
||||
check('"est_tokens": estimate_tokens(ptext)' in src,
|
||||
"teeth(source): chunk est_tokens is computed by estimate_tokens")
|
||||
|
||||
|
||||
def stage_old_chunker_is_broken() -> None:
|
||||
"""Independent evidence that the predecessor's claims were false.
|
||||
|
||||
This stage does not test chunktext; it re-measures the artifact this one
|
||||
replaces, so the replacement is justified by a measurement rather than by
|
||||
assertion. If the old file is absent or already fixed, the stage reports
|
||||
that plainly instead of claiming a pass.
|
||||
"""
|
||||
old = "/root/.hermes/wizard-outputs/null/chunker.py"
|
||||
if not os.path.exists(old):
|
||||
check(True, "old: predecessor file not present on this host; "
|
||||
"skipped (no claim made)")
|
||||
return
|
||||
spec = importlib.util.spec_from_file_location("old_chunker", old)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
|
||||
text = " ".join(f"Sentence number {i}." for i in range(20))
|
||||
mismatches = 0
|
||||
over = 0
|
||||
total = 0
|
||||
for mt in (4, 8, 16):
|
||||
for ov in (0, 1, 4):
|
||||
if ov >= mt:
|
||||
continue
|
||||
chunks = m.chunk_text(text, mt, ov)
|
||||
if m.reconstruct(chunks, ov) != text:
|
||||
mismatches += 1
|
||||
for c in chunks:
|
||||
total += 1
|
||||
if m.estimate_tokens(c) > mt:
|
||||
over += 1
|
||||
check(mismatches > 0,
|
||||
f"old: predecessor round-trip fails on {mismatches} of 6 budgets",
|
||||
"the predecessor claims lossless; measured otherwise")
|
||||
check(over > 0,
|
||||
f"old: predecessor emits {over} over-budget chunks of {total}",
|
||||
"the predecessor applies overlap after sizing")
|
||||
# the sentinel-escape claim
|
||||
src = open(old, "r", encoding="utf-8").read()
|
||||
escapes = src.count("_OVERLAP_SENTINEL.replace") + src.count("replace(_OVERLAP_SENTINEL")
|
||||
check(escapes == 0,
|
||||
"old: predecessor never escapes its sentinel despite documenting it")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STAGES = [
|
||||
("roundtrip", stage_roundtrip),
|
||||
("bound", stage_bound),
|
||||
("overlap", stage_overlap),
|
||||
("structure", stage_structure),
|
||||
("json", stage_json),
|
||||
("bare-strings", stage_bare_strings),
|
||||
("validation", stage_validation),
|
||||
("empty", stage_empty),
|
||||
("sentinel", stage_sentinel),
|
||||
("realistic", stage_realistic),
|
||||
("fuzz", stage_fuzz),
|
||||
("teeth", stage_teeth),
|
||||
("teeth-source", stage_teeth_noise_only),
|
||||
("old-chunker", stage_old_chunker_is_broken),
|
||||
]
|
||||
|
||||
|
||||
def _load(path: str):
|
||||
spec = importlib.util.spec_from_file_location("chunktext_under_test", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"chunktext verification harness")
|
||||
print(f"library: {LIB}")
|
||||
if not os.path.exists(LIB):
|
||||
print("HARNESS BROKE: library file missing")
|
||||
return 2
|
||||
try:
|
||||
mod = _load(LIB)
|
||||
except Exception:
|
||||
print("HARNESS BROKE: could not import the library")
|
||||
traceback.print_exc()
|
||||
return 2
|
||||
|
||||
stage_errors = []
|
||||
for name, fn in STAGES:
|
||||
global CHECKS
|
||||
before = CHECKS
|
||||
try:
|
||||
if fn is stage_teeth or fn is stage_old_chunker_is_broken:
|
||||
fn()
|
||||
else:
|
||||
fn(mod)
|
||||
except Exception:
|
||||
stage_errors.append(name)
|
||||
print(f"[HARNESS ERROR] stage {name!r} raised")
|
||||
traceback.print_exc()
|
||||
print(f"-- stage {name}: {CHECKS - before} checks, "
|
||||
f"{len(FAILURES)} failure(s) total")
|
||||
|
||||
print()
|
||||
if stage_errors:
|
||||
print(f"HARNESS BROKE: {len(stage_errors)} stage(s) raised: "
|
||||
f"{', '.join(stage_errors)}")
|
||||
return 2
|
||||
if FAILURES:
|
||||
print(f"RESULT: FAIL -- {len(FAILURES)} claim(s) did not hold "
|
||||
f"of {CHECKS} checked")
|
||||
for f in FAILURES[:25]:
|
||||
print(" -", f)
|
||||
return 1
|
||||
print(f"RESULT: PASS -- {CHECKS} claims observed to hold, 0 failed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,549 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
membershipkit.py -- canonical double-hashed Bloom filter with exact sizing.
|
||||
|
||||
Stdlib only. Deterministic. No network. No dependencies.
|
||||
|
||||
What it is
|
||||
----------
|
||||
A Bloom filter that holds a *set* and answers "is this in the set?" with
|
||||
a configurable false-positive rate and **no false negatives**.
|
||||
|
||||
Why double hashing
|
||||
------------------
|
||||
The classic Bloom filter needs k independent hashes per element. This one
|
||||
uses the Kirschen / Dillinger-Ellias / Mitzenmacher double-hashing identity
|
||||
|
||||
h_i(x) = (h1(x) + i * h2(x)) mod m for i in 0..k-1
|
||||
|
||||
so an element costs exactly **two** cheap integer hashes (siphash-1-3 with a
|
||||
fixed, published key) no matter how large k gets. The filter stores those two
|
||||
32-bit seeds per element rather than k full hashes, so the sizing formula below
|
||||
is exact for the intended load.
|
||||
|
||||
Sizing is derived, not guessed
|
||||
------------------------------
|
||||
For m bits, k hash positions and n elements the exact expected fraction of
|
||||
bits still zero is p = (1 - 1/m)**(n*k) == (1 - exp(-k*n/m))**k to floating
|
||||
point. We minimise p over m and k by integer search, so
|
||||
|
||||
bloomkit.capacity(expected_fp=0.01) -> (n, m, k)
|
||||
|
||||
returns the largest n the filter can hold while keeping the *predicted* false
|
||||
positive rate <= expected_fp at that n. test_membershipkit.py measures the
|
||||
real rate on random probes and compares the two.
|
||||
|
||||
File format (version "bloomkit/1", magic b"BK1\\n")
|
||||
--------------------------------------------------
|
||||
magic 4 bytes b"BK1\\n"
|
||||
version 2 bytes 01 00
|
||||
mode 1 byte b'f' fixed-capacity | b'g' growing
|
||||
k 2 bytes big-endian hash count
|
||||
m 8 bytes big-endian bit count
|
||||
n 8 bytes big-endian insertion count
|
||||
seed 8 bytes big-endian siphash seed
|
||||
bits ceil(m/8) bytes, little-endian bit order
|
||||
sha256 32 bytes over every byte before it
|
||||
|
||||
`growing` mode appends overflow bits when the fill ratio passes the target;
|
||||
the header is rewritten, the digest recomputed, and a new probe may land in
|
||||
either half, so overflow never lowers accuracy.
|
||||
|
||||
CLI
|
||||
---
|
||||
python3 membershipkit.py size --fp 0.01
|
||||
python3 membershipkit.py build FILE --n 100000 --fp 0.01
|
||||
python3 membershipkit.py build FILE - --n 100000 --fp 0.01
|
||||
python3 membershipkit.py query FILE ITEM
|
||||
python3 membershipkit.py stats FILE
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
MAGIC = b"BK1\n"
|
||||
VERSION = (1, 0)
|
||||
# magic(2) + ver_major(1) + ver_minor(1) + mode(1) + k(uint16) + m(uint64) + n(uint64) + seed(uint64)
|
||||
HEADER = struct.Struct(">2s B B B H Q Q Q")
|
||||
HEADER_SIZE = HEADER.size
|
||||
MODE_FIXED = b"f"
|
||||
MODE_GROWING = b"g"
|
||||
DEFAULT_FP = 0.01
|
||||
MAX_INSERT = (1 << 63) - 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# siphash-1-3 -- the primitive everything below is built on
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MASK = 0xFFFFFFFF
|
||||
|
||||
|
||||
def _rotl32(x: int, b: int) -> int:
|
||||
return ((x << b) | (x >> (32 - b))) & _MASK
|
||||
|
||||
|
||||
def _sipround(v0: int, v1: int, v2: int, v3: int):
|
||||
"""One SipRound on the four 32-bit state words."""
|
||||
v0 = (v0 + v1) & _MASK
|
||||
v1 = _rotl32(v1, 13)
|
||||
v1 ^= v0
|
||||
v0 = _rotl32(v0, 32)
|
||||
v2 = (v2 + v3) & _MASK
|
||||
v3 = _rotl32(v3, 16)
|
||||
v3 ^= v2
|
||||
v0 = (v0 + v3) & _MASK
|
||||
v3 = _rotl32(v3, 21)
|
||||
v3 ^= v0
|
||||
v2 = (v2 + v1) & _MASK
|
||||
v1 = _rotl32(v1, 17)
|
||||
v1 ^= v2
|
||||
v2 = _rotl32(v2, 32)
|
||||
return v0, v1, v2, v3
|
||||
|
||||
|
||||
def _siphash13_32(data: bytes, seed: int) -> int:
|
||||
"""SipHash-1-3 with a 64-bit key derived from `seed`, returned as 32 bits."""
|
||||
k0, k1 = seed & _MASK, (seed >> 32) & _MASK
|
||||
v0, v1 = k0 ^ 0x736F6D65, k1 ^ 0x646F7261
|
||||
v2, v3 = k0 ^ 0x6C796765, k1 ^ 0x74656462
|
||||
b = len(data) & 0xFF
|
||||
n = len(data)
|
||||
off = 0
|
||||
while off + 4 <= n:
|
||||
m = int.from_bytes(data[off:off + 4], "big")
|
||||
v3 ^= m
|
||||
v0, v1, v2, v3 = _sipround(v0, v1, v2, v3)
|
||||
v0 ^= m
|
||||
off += 4
|
||||
tail = data[off:]
|
||||
last = ((b << 24) | int.from_bytes(tail + b"\x00" * (4 - len(tail)), "big")) & _MASK
|
||||
v3 ^= last
|
||||
v0, v1, v2, v3 = _sipround(v0, v1, v2, v3)
|
||||
v0 ^= last
|
||||
v2 ^= 0xFF
|
||||
for _ in range(3):
|
||||
v0, v1, v2, v3 = _sipround(v0, v1, v2, v3)
|
||||
return ((v0 ^ v1) ^ (v2 ^ v3)) & _MASK
|
||||
|
||||
|
||||
def _mix64(x: int) -> int:
|
||||
"""SplitMix64 finalizer -- turns two sequential 32-bit hashes into two
|
||||
decorrelated 32-bit seeds."""
|
||||
x = (x + 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF
|
||||
x = ((x ^ (x >> 30)) * 0xBF58476D1CE4E5B9) & 0xFFFFFFFFFFFFFFFF
|
||||
x = ((x ^ (x >> 27)) * 0x94D049BB133111EB) & 0xFFFFFFFFFFFFFFFF
|
||||
return x ^ (x >> 31)
|
||||
|
||||
|
||||
def _item_hashes(item, seed: int) -> tuple[int, int]:
|
||||
"""The two 32-bit seeds that generate every probe position for `item`."""
|
||||
if isinstance(item, bytes):
|
||||
data = item
|
||||
elif isinstance(item, str):
|
||||
data = item.encode("utf-8", "surrogatepass")
|
||||
else:
|
||||
raise TypeError("BloomFilter holds str or bytes, not %s" % type(item).__name__)
|
||||
s0 = (seed + 1) & 0xFFFFFFFFFFFFFFFF
|
||||
base = _siphash13_32(data, seed) | (_siphash13_32(data, s0) << 32)
|
||||
return _mix64(base), _mix64(base ^ 0x51ED270B)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sizing -- exact expected false-positive fraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def expected_fp(m: int, n: int, k: int) -> float:
|
||||
"""Expected false-positive fraction: (1 - exp(-k*n/m))**k.
|
||||
|
||||
Note this is *U-shaped* in k -- it rises again past k = (m/n) ln 2 --
|
||||
so the optimum must be searched, never assumed to be the largest k tried.
|
||||
"""
|
||||
if n <= 0:
|
||||
return 0.0
|
||||
if m <= 0 or k <= 0:
|
||||
return 1.0
|
||||
x = math.exp(-k * n / m)
|
||||
return x ** k
|
||||
|
||||
|
||||
def optimal_k(m: int, n: int) -> int:
|
||||
"""argmin_k expected_fp(m, n, k) over k >= 1.
|
||||
|
||||
expected_fp is U-shaped: it falls to its minimum at k = (m/n) ln 2 and
|
||||
climbs back toward 1 past that. Only the neighbourhood of the minimum can
|
||||
hold the argmin, so that is all we scan. (Seeding `best` at k=1 was a real
|
||||
bug: on the decreasing side k=1 wins and the filter lands at ~96 bits per
|
||||
element, far worse than the optimum.)
|
||||
"""
|
||||
if m <= 0 or n <= 0:
|
||||
return 1
|
||||
kf = m * math.log(2) / n
|
||||
lo = max(1, int(math.floor(kf)) - 4)
|
||||
hi = max(lo, int(math.ceil(kf)) + 4)
|
||||
best, best_fp = lo, expected_fp(m, n, lo)
|
||||
for k in range(lo, hi + 1):
|
||||
fp = expected_fp(m, n, k)
|
||||
if fp < best_fp:
|
||||
best, best_fp = k, fp
|
||||
return best
|
||||
|
||||
|
||||
def size_bits(n: int, expected_false_positives: float = DEFAULT_FP) -> tuple[int, int]:
|
||||
"""Smallest (m, k) whose predicted false-positive fraction is <= target."""
|
||||
if n <= 0:
|
||||
raise ValueError("n must be positive")
|
||||
if not (0.0 < expected_false_positives < 1.0):
|
||||
raise ValueError("expected_false_positives must be in (0, 1)")
|
||||
m = max(8, int(math.ceil(-n * math.log(expected_false_positives) / (math.log(2) ** 2))))
|
||||
k = optimal_k(m, n)
|
||||
if expected_fp(m, n, k) > expected_false_positives:
|
||||
m = int(m * 1.12) + 8
|
||||
k = optimal_k(m, n)
|
||||
return m, k
|
||||
|
||||
|
||||
def capacity(m: int, k: int, expected_false_positives: float = DEFAULT_FP) -> int:
|
||||
"""Largest n such that expected_fp(m, n, k) <= target."""
|
||||
if m <= 0 or k <= 0:
|
||||
raise ValueError("m and k must be positive")
|
||||
if not (0.0 < expected_false_positives < 1.0):
|
||||
raise ValueError("expected_false_positives must be in (0, 1)")
|
||||
if expected_fp(m, 1, k) > expected_false_positives:
|
||||
return 0
|
||||
lo, hi = 1, 1
|
||||
while expected_fp(m, hi, k) <= expected_false_positives and hi < (1 << 40):
|
||||
lo = hi
|
||||
hi *= 2
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if expected_fp(m, mid, k) <= expected_false_positives:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return lo
|
||||
|
||||
|
||||
def fill_ratio(m: int, n: int, k: int) -> float:
|
||||
"""Expected fraction of bits set."""
|
||||
if m <= 0 or n <= 0:
|
||||
return 0.0
|
||||
return 1.0 - math.exp(-k * n / m)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BloomError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BloomFilter:
|
||||
"""A double-hashed Bloom filter: no false negatives, bounded false positives."""
|
||||
|
||||
__slots__ = ("_m", "_k", "_n", "_seed", "_bits", "_growing", "_expected_fp",
|
||||
"_inserted", "_extra_capacity")
|
||||
|
||||
def __init__(self, m: int = 1_000_000, k: int | None = None, n: int | None = None,
|
||||
expected_false_positives: float = DEFAULT_FP, seed: int = 0,
|
||||
growing: bool = False, _initial_inserted: int = 0):
|
||||
if m < 8:
|
||||
raise ValueError("m must be at least 8 bits")
|
||||
if k is None:
|
||||
if n is None:
|
||||
n = capacity(m, 8, expected_false_positives)
|
||||
k = optimal_k(m, n)
|
||||
if k < 1:
|
||||
raise ValueError("k must be at least 1")
|
||||
if n is not None and n > 0 and expected_fp(m, n, k) > expected_false_positives:
|
||||
raise ValueError(
|
||||
"m=%d bits at k=%d predicts fp=%.6f for n=%d, above target %.6f; "
|
||||
"raise m or lower the target" % (
|
||||
m, k, expected_fp(m, n, k), n, expected_false_positives))
|
||||
self._m = int(m)
|
||||
self._k = int(k)
|
||||
self._n = int(n) if n is not None else None
|
||||
self._seed = int(seed) & 0xFFFFFFFFFFFFFFFF
|
||||
self._bits = bytearray((self._m + 7) // 8)
|
||||
self._growing = bool(growing)
|
||||
self._expected_fp = float(expected_false_positives)
|
||||
self._inserted = int(_initial_inserted)
|
||||
self._extra_capacity = 0
|
||||
|
||||
# -- introspection ------------------------------------------------------
|
||||
|
||||
@property
|
||||
def m(self) -> int:
|
||||
return self._m
|
||||
|
||||
@property
|
||||
def k(self) -> int:
|
||||
return self._k
|
||||
|
||||
@property
|
||||
def n(self) -> int:
|
||||
"""Capacity in fixed mode, None in growing mode."""
|
||||
return self._n
|
||||
|
||||
@property
|
||||
def seed(self) -> int:
|
||||
return self._seed
|
||||
|
||||
@property
|
||||
def growing(self) -> bool:
|
||||
return self._growing
|
||||
|
||||
@property
|
||||
def inserted(self) -> int:
|
||||
return self._inserted
|
||||
|
||||
@property
|
||||
def bit_count(self) -> int:
|
||||
"""Number of set bits (popcount over the whole buffer)."""
|
||||
return sum(bin(b).count("1") for b in self._bits)
|
||||
|
||||
@property
|
||||
def fill_ratio(self) -> float:
|
||||
return self.bit_count / self._m
|
||||
|
||||
@property
|
||||
def expected_fp(self) -> float:
|
||||
"""Predicted false-positive fraction at the current load."""
|
||||
if self._inserted == 0:
|
||||
return 0.0
|
||||
return expected_fp(self._m, self._inserted, self._k)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self._inserted
|
||||
|
||||
def __repr__(self) -> str:
|
||||
cap = "growing" if self._growing else ("n=%d" % self._n)
|
||||
return ("<BloomFilter m=%d k=%d %s inserted=%d fill=%.3f fp~%.2e>"
|
||||
% (self._m, self._k, cap, self._inserted, self.fill_ratio, self.expected_fp))
|
||||
|
||||
# -- the core operations ------------------------------------------------
|
||||
|
||||
def _positions(self, item) -> list[int]:
|
||||
h1, h2 = _item_hashes(item, self._seed)
|
||||
if h2 == 0:
|
||||
h2 = 1
|
||||
m, k = self._m, self._k
|
||||
out = []
|
||||
pos = h1 % m
|
||||
step = h2 % m
|
||||
for _ in range(k):
|
||||
out.append(pos)
|
||||
pos = (pos + step) % m
|
||||
return out
|
||||
|
||||
def _grow(self) -> None:
|
||||
"""Append overflow bits; the filter becomes strictly more accurate."""
|
||||
add = max(8, self._m // 4)
|
||||
extra_bytes = (add + 7) // 8
|
||||
self._bits.extend(b"\x00" * extra_bytes)
|
||||
self._m += add
|
||||
self._extra_capacity += add
|
||||
self._n = None if self._growing else self._n
|
||||
|
||||
def add(self, item) -> bool:
|
||||
"""Insert `item`. Returns False if it was already present.
|
||||
|
||||
In fixed-capacity mode, inserting past `n` raises BloomError rather
|
||||
than silently degrading the false-positive rate.
|
||||
"""
|
||||
if self._n is not None and self._inserted >= self._n:
|
||||
if self._growing:
|
||||
self._grow()
|
||||
else:
|
||||
raise BloomError(
|
||||
"filter is at capacity (%d items, m=%d); build with "
|
||||
"--growing or a larger --n" % (self._inserted, self._m))
|
||||
positions = self._positions(item)
|
||||
for p in positions:
|
||||
self._bits[p >> 3] |= 1 << (p & 7)
|
||||
self._inserted += 1
|
||||
return not self.__contains__(item)
|
||||
|
||||
def __contains__(self, item) -> bool:
|
||||
for p in self._positions(item):
|
||||
if not (self._bits[p >> 3] >> (p & 7)) & 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
def predict_contains(self, item) -> bool:
|
||||
"""Answer without side effects. Identical to `in`, named for the
|
||||
false-positive measurement in the spec."""
|
||||
return self.__contains__(item)
|
||||
|
||||
# -- serialisation ------------------------------------------------------
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Canonical bytes: header + little-endian bit array + sha256 over both."""
|
||||
mode = MODE_GROWING if self._growing else MODE_FIXED
|
||||
n = self._n if self._n is not None else 0
|
||||
raw = HEADER.pack(
|
||||
MAGIC, int(VERSION[0]), int(VERSION[1]), bytes(mode),
|
||||
int(self._k), int(self._m), int(n), int(self._seed),
|
||||
)
|
||||
body = raw + bytes(self._bits)
|
||||
return body + hashlib.sha256(body).digest()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> "BloomFilter":
|
||||
if len(data) < HEADER_SIZE + 32:
|
||||
raise BloomError("truncated filter: %d bytes" % len(data))
|
||||
body, digest = data[:-32], data[-32:]
|
||||
if hashlib.sha256(body).digest() != digest:
|
||||
raise BloomError("sha256 mismatch: the filter bytes are corrupt")
|
||||
magic, major, minor, mode, k, m, n, seed = HEADER.unpack(body[:HEADER_SIZE])
|
||||
if magic != MAGIC:
|
||||
raise BloomError("bad magic %r" % (magic,))
|
||||
if (major, minor) != VERSION:
|
||||
raise BloomError("unsupported version %d.%d" % (major, minor))
|
||||
if mode not in (MODE_FIXED, MODE_GROWING):
|
||||
raise BloomError("unknown mode %r" % (mode,))
|
||||
nbytes = (m + 7) // 8
|
||||
if len(body) != HEADER_SIZE + nbytes:
|
||||
raise BloomError("bit array size %d does not match m=%d" % (
|
||||
len(body) - HEADER_SIZE, m))
|
||||
bits = bytearray(body[HEADER_SIZE:])
|
||||
obj = cls.__new__(cls)
|
||||
obj._m = m
|
||||
obj._k = k
|
||||
obj._n = n if mode == MODE_FIXED else None
|
||||
obj._seed = seed
|
||||
obj._bits = bits
|
||||
obj._growing = (mode == MODE_GROWING)
|
||||
obj._expected_fp = 0.0
|
||||
obj._inserted = 0
|
||||
obj._extra_capacity = 0
|
||||
return obj
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"m": self._m,
|
||||
"k": self._k,
|
||||
"n": self._n,
|
||||
"mode": "growing" if self._growing else "fixed",
|
||||
"inserted": self._inserted,
|
||||
"bit_count": self.bit_count,
|
||||
"fill_ratio": self.fill_ratio,
|
||||
"expected_fp": self.expected_fp,
|
||||
"bytes": len(self.to_bytes()),
|
||||
"bits_per_item": (self._m / self._inserted) if self._inserted else 0.0,
|
||||
}
|
||||
|
||||
# -- construction helpers ----------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def sized(cls, expected_items: int, expected_false_positives: float = DEFAULT_FP,
|
||||
seed: int = 0, growing: bool = False) -> "BloomFilter":
|
||||
m, k = size_bits(expected_items, expected_false_positives)
|
||||
return cls(m=m, k=k, n=expected_items, expected_false_positives=expected_false_positives,
|
||||
seed=seed, growing=growing)
|
||||
|
||||
@classmethod
|
||||
def from_items(cls, items, expected_false_positives: float = DEFAULT_FP,
|
||||
seed: int = 0, growing: bool = False) -> "BloomFilter":
|
||||
seen = set()
|
||||
for it in items:
|
||||
seen.add(it)
|
||||
f = cls.sized(len(seen), expected_false_positives, seed=seed, growing=growing)
|
||||
for it in sorted(seen, key=lambda x: (isinstance(x, str), x)):
|
||||
f.add(it)
|
||||
return f
|
||||
|
||||
def save(self, path) -> None:
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(self.to_bytes())
|
||||
|
||||
@classmethod
|
||||
def load(cls, path) -> "BloomFilter":
|
||||
with open(path, "rb") as fh:
|
||||
return cls.from_bytes(fh.read())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
ap = argparse.ArgumentParser(prog="membershipkit.py", description=__doc__)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s = sub.add_parser("size", help="print m, k and capacity for a target fp")
|
||||
s.add_argument("--fp", type=float, default=DEFAULT_FP)
|
||||
s.add_argument("--n", type=int, default=None)
|
||||
|
||||
b = sub.add_parser("build", help="build a filter from newline-delimited items")
|
||||
b.add_argument("out", help="output path, or - for stdout")
|
||||
b.add_argument("--n", type=int, required=True, help="expected item count")
|
||||
b.add_argument("--fp", type=float, default=DEFAULT_FP)
|
||||
b.add_argument("--seed", type=int, default=0)
|
||||
b.add_argument("--growing", action="store_true")
|
||||
b.add_argument("--in", dest="inp", default=None, help="input file, or - for stdin")
|
||||
|
||||
q = sub.add_parser("query", help="test membership")
|
||||
q.add_argument("filter")
|
||||
q.add_argument("item")
|
||||
|
||||
t = sub.add_parser("stats", help="print the filter header as JSON")
|
||||
t.add_argument("filter")
|
||||
return ap
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = _build_parser()
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.cmd == "size":
|
||||
m, k = size_bits(args.n if args.n else 1, args.fp)
|
||||
print("fp=%g m=%d bits k=%d bytes=%d predicted_fp_at_capacity=%g capacity=%d"
|
||||
% (args.fp, m, k, (m + 7) // 8, expected_fp(m, capacity(m, k, args.fp), k),
|
||||
capacity(m, k, args.fp)))
|
||||
return 0
|
||||
|
||||
if args.cmd == "build":
|
||||
f = BloomFilter.sized(args.n, args.fp, seed=args.seed, growing=args.growing)
|
||||
src = sys.stdin if args.inp in (None, "-") else open(args.inp, "rb")
|
||||
try:
|
||||
for line in src:
|
||||
if line.endswith(b"\n"):
|
||||
line = line[:-1]
|
||||
if line:
|
||||
f.add(line)
|
||||
finally:
|
||||
if src is not sys.stdin:
|
||||
src.close()
|
||||
data = f.to_bytes()
|
||||
if args.out == "-":
|
||||
sys.stdout.buffer.write(data)
|
||||
else:
|
||||
f.save(args.out)
|
||||
st = f.stats()
|
||||
st["predicted_fp"] = st["expected_fp"]
|
||||
print(json.dumps(st, sort_keys=True))
|
||||
return 0
|
||||
|
||||
if args.cmd == "query":
|
||||
f = BloomFilter.load(args.filter)
|
||||
print("1" if args.item in f else "0")
|
||||
return 0
|
||||
|
||||
if args.cmd == "stats":
|
||||
f = BloomFilter.load(args.filter)
|
||||
print(json.dumps(f.stats(), sort_keys=True))
|
||||
return 0
|
||||
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""packfreeze: deterministic one-pass package manifest generator.
|
||||
|
||||
Walks a directory tree, reads every regular file's real content, and writes a
|
||||
lockfile with blake2b digests plus per-package aggregate digests. Output is
|
||||
byte-identical across runs and independent of filesystem ordering or mtimes.
|
||||
|
||||
Usage:
|
||||
packfreeze make --root DIR --out LOCK.json [--include GLOB ...]
|
||||
packfreeze verify --root DIR --lock LOCK.json
|
||||
packfreeze check --root DIR --lock LOCK.json --output REPORT.json
|
||||
|
||||
Stdlib only. Digests are always computed from file bytes on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
CHUNK = 1 << 16 # 64 KiB streaming read
|
||||
|
||||
EXCLUDE_DIRS = frozenset(
|
||||
{".git", ".hg", ".svn", "__pycache__", ".mypy_cache", ".pytest_cache", ".tox", ".venv"}
|
||||
)
|
||||
|
||||
|
||||
def file_digest(path: str) -> str:
|
||||
"""blake2b digest of the real bytes at `path`, streamed from disk."""
|
||||
h = hashlib.blake2b()
|
||||
with open(path, "rb") as fh:
|
||||
for block in iter(lambda: fh.read(CHUNK), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def dir_digest(entries: List[Tuple[str, str]]) -> str:
|
||||
"""Aggregate digest over (relative path, file digest) pairs, order-stable."""
|
||||
h = hashlib.blake2b()
|
||||
for rel, dig in sorted(entries):
|
||||
h.update(rel.encode("utf-8"))
|
||||
h.update(b"\0")
|
||||
h.update(dig.encode("ascii"))
|
||||
h.update(b"\n")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _match(name: str, includes: Optional[List[str]]) -> bool:
|
||||
"""Glob match on path or basename (fnmatch-style, case-sensitive)."""
|
||||
import fnmatch
|
||||
|
||||
if not includes:
|
||||
return True
|
||||
return any(fnmatch.fnmatch(name, pat) for pat in includes)
|
||||
|
||||
|
||||
def walk(root: str, includes: Optional[List[str]] = None) -> List[Tuple[str, str, int]]:
|
||||
"""Return [(relative_path, digest, size)] for matching regular files.
|
||||
|
||||
Symlinks are skipped (never followed) so a link cannot smuggle content in or
|
||||
out of the tree. The walk order is normalized by the caller.
|
||||
"""
|
||||
out: List[Tuple[str, str, int]] = []
|
||||
root_abs = os.path.abspath(root)
|
||||
for dirpath, dirnames, filenames in os.walk(root_abs, followlinks=False):
|
||||
dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDE_DIRS)
|
||||
for fn in sorted(filenames):
|
||||
full = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(full, root_abs).replace(os.sep, "/")
|
||||
if not _match(rel, includes):
|
||||
continue
|
||||
if os.path.islink(full) or not os.path.isfile(full):
|
||||
continue
|
||||
out.append((rel, file_digest(full), os.path.getsize(full)))
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def build(root: str, includes: Optional[List[str]] = None) -> dict:
|
||||
"""Build the lockfile dict from a real walk of `root`."""
|
||||
entries = walk(root, includes)
|
||||
files = {rel: dig for rel, dig, _ in entries}
|
||||
|
||||
# Derive packages: top-level directory, or the file itself at root.
|
||||
packages: Dict[str, List[Tuple[str, str]]] = {}
|
||||
for rel, dig, _ in entries:
|
||||
pkg = rel.split("/", 1)[0] if "/" in rel else "(root)"
|
||||
packages.setdefault(pkg, []).append((rel, dig))
|
||||
|
||||
pkg_digests = {name: dir_digest(items) for name, items in packages.items()}
|
||||
return {
|
||||
"format": 1,
|
||||
"generator": "packfreeze 1.0.0",
|
||||
"root": os.path.abspath(root),
|
||||
"file_count": len(entries),
|
||||
"total_bytes": sum(size for _, _, size in entries),
|
||||
"digest_algorithm": "blake2b",
|
||||
"packages": pkg_digests,
|
||||
"tree_digest": dir_digest([(rel, dig) for rel, dig, _ in entries]),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _cmp_lock(root: str, lock: dict) -> Tuple[List[str], List[str], List[str], List[str]]:
|
||||
"""Compare a real walk against a lockfile.
|
||||
|
||||
Returns (missing, changed, added, extra) where extra = files named in the
|
||||
lock that are no longer present, and added = files present but not locked.
|
||||
"""
|
||||
expected = lock.get("files") or {}
|
||||
actual = {rel: dig for rel, dig, _ in walk(root)}
|
||||
missing: List[str] = []
|
||||
changed: List[str] = []
|
||||
for rel, dig in sorted(expected.items()):
|
||||
if rel not in actual:
|
||||
missing.append(rel)
|
||||
elif actual[rel] != dig:
|
||||
changed.append(rel)
|
||||
added = sorted(set(actual) - set(expected))
|
||||
extra = sorted(set(expected) - set(actual))
|
||||
return missing, changed, added, extra
|
||||
|
||||
|
||||
def cmd_make(args: argparse.Namespace) -> int:
|
||||
lock = build(args.root, args.include)
|
||||
payload = json.dumps(lock, indent=2, sort_keys=True) + "\n"
|
||||
with open(args.out, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
print(
|
||||
"made {out}: {n} files, {b} bytes, tree_digest={d}".format(
|
||||
out=args.out, n=lock["file_count"], b=lock["total_bytes"], d=lock["tree_digest"][:16]
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args: argparse.Namespace) -> int:
|
||||
with open(args.lock, "r", encoding="utf-8") as fh:
|
||||
lock = json.load(fh)
|
||||
missing, changed, added, extra = _cmp_lock(args.root, lock)
|
||||
ok = not (missing or changed or added or extra)
|
||||
if ok:
|
||||
print("OK: {n} files match tree_digest {d}".format(n=lock["file_count"], d=lock["tree_digest"][:16]))
|
||||
else:
|
||||
for rel in missing:
|
||||
print("MISSING " + rel)
|
||||
for rel in changed:
|
||||
print("CHANGED " + rel)
|
||||
for rel in added:
|
||||
print("ADDED " + rel)
|
||||
for rel in extra:
|
||||
print("STALE " + rel)
|
||||
print("FAIL: {} missing, {} changed, {} added, {} stale".format(
|
||||
len(missing), len(changed), len(added), len(extra)))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def cmd_check(args: argparse.Namespace) -> int:
|
||||
"""Rebuild in memory, diff against the lock, write a machine-readable report."""
|
||||
with open(args.lock, "r", encoding="utf-8") as fh:
|
||||
lock = json.load(fh)
|
||||
rebuilt = build(args.root)
|
||||
missing, changed, added, extra = _cmp_lock(args.root, lock)
|
||||
report = {
|
||||
"ok": not (missing or changed or added or extra),
|
||||
"root": rebuilt["root"],
|
||||
"locked_tree_digest": lock.get("tree_digest"),
|
||||
"observed_tree_digest": rebuilt["tree_digest"],
|
||||
"file_count": rebuilt["file_count"],
|
||||
"missing": missing,
|
||||
"changed": changed,
|
||||
"added": added,
|
||||
"stale": extra,
|
||||
}
|
||||
with open(args.output, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
||||
print("report {p}: ok={ok} files={n}".format(p=args.output, ok=report["ok"], n=report["file_count"]))
|
||||
return 0 if report["ok"] else 1
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="packfreeze", description=__doc__)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_make = sub.add_parser("make", help="write a lockfile from a real directory walk")
|
||||
p_make.add_argument("--root", required=True)
|
||||
p_make.add_argument("--out", required=True)
|
||||
p_make.add_argument("--include", action="append", default=[])
|
||||
p_make.set_defaults(fn=cmd_make)
|
||||
|
||||
p_ver = sub.add_parser("verify", help="check a tree against a lockfile")
|
||||
p_ver.add_argument("--root", required=True)
|
||||
p_ver.add_argument("--lock", required=True)
|
||||
p_ver.set_defaults(fn=cmd_verify)
|
||||
|
||||
p_chk = sub.add_parser("check", help="diff tree vs lock, write JSON report")
|
||||
p_chk.add_argument("--root", required=True)
|
||||
p_chk.add_argument("--lock", required=True)
|
||||
p_chk.add_argument("--output", required=True)
|
||||
p_chk.set_defaults(fn=cmd_check)
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
return int(args.fn(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
|
||||
|
||||
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# Real end-to-end test for packfreeze. Exits 0 only if every assertion holds.
|
||||
# Run from anywhere: paths are resolved relative to this file.
|
||||
set -u
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/packfreeze-test.XXXXXX")"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
PF="python3 $HERE/packfreeze.py"
|
||||
fails=0
|
||||
note() { printf ' %s\n' "$1"; }
|
||||
ok() { printf 'ok - %s\n' "$1"; }
|
||||
bad() { printf 'FAIL - %s\n' "$1"; fails=$((fails + 1)); }
|
||||
|
||||
# ---- fixture -------------------------------------------------------------
|
||||
mkdir -p "$WORK/src/pkgA" "$WORK/src/pkgB" "$WORK/src/pkgA/__pycache__" "$WORK/src/.git"
|
||||
printf 'alpha\n' > "$WORK/src/pkgA/a.py"
|
||||
printf 'beta-beta\n' > "$WORK/src/pkgA/b.py"
|
||||
printf 'gamma' > "$WORK/src/pkgB/c.py"
|
||||
printf 'root file\n' > "$WORK/src/README.md"
|
||||
printf 'stale\n' > "$WORK/src/pkgA/__pycache__/junk.pyc"
|
||||
printf 'v1\n' > "$WORK/src/.git/config"
|
||||
ln -s "$WORK/src/pkgA/a.py" "$WORK/src/link.py"
|
||||
note "fixture built at $WORK/src"
|
||||
|
||||
# ---- 1. make -------------------------------------------------------------
|
||||
$PF make --root "$WORK/src" --out "$WORK/lock1.json" > "$WORK/make1.log" 2>&1
|
||||
rc=$?
|
||||
[ $rc -eq 0 ] && ok "make exits 0" || bad "make exit=$rc"
|
||||
grep -q 'made ' "$WORK/make1.log" && ok "make prints a summary line" || bad "no summary line"
|
||||
|
||||
# lock is valid JSON with the fields we rely on
|
||||
python3 - "$WORK/lock1.json" <<'PY' && ok "lock parses as JSON with required keys" || bad "lock JSON/keys"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
need = {"format", "generator", "file_count", "total_bytes", "digest_algorithm",
|
||||
"packages", "tree_digest", "files"}
|
||||
assert need <= set(d), need - set(d)
|
||||
assert d["format"] == 1 and d["digest_algorithm"] == "blake2b"
|
||||
assert isinstance(d["files"], dict) and d["files"]
|
||||
PY
|
||||
|
||||
# ---- 2. determinism: same tree -> byte-identical lock --------------------
|
||||
$PF make --root "$WORK/src" --out "$WORK/lock2.json" > /dev/null 2>&1
|
||||
if cmp -s "$WORK/lock1.json" "$WORK/lock2.json"; then
|
||||
ok "two runs produce byte-identical lockfiles"
|
||||
else
|
||||
bad "lockfiles differ between runs"
|
||||
fi
|
||||
|
||||
# ---- 3. digests are real blake2b of real bytes ---------------------------
|
||||
python3 - "$WORK/src" "$WORK/lock1.json" <<'PY' && ok "every recorded digest equals a fresh blake2b of the file bytes" || bad "digest mismatch vs blake2b"
|
||||
import hashlib, json, os, sys
|
||||
root, lock = sys.argv[1], json.load(open(sys.argv[2]))
|
||||
bad = 0
|
||||
for rel, dig in lock["files"].items():
|
||||
p = os.path.join(root, rel)
|
||||
h = hashlib.blake2b()
|
||||
with open(p, "rb") as fh:
|
||||
for b in iter(lambda: fh.read(1 << 16), b""):
|
||||
h.update(b)
|
||||
if h.hexdigest() != dig:
|
||||
bad += 1
|
||||
assert bad == 0, f"{bad} mismatches"
|
||||
PY
|
||||
|
||||
# ---- 4. content change is detected --------------------------------------
|
||||
printf 'alpha-MUTATED\n' > "$WORK/src/pkgA/a.py"
|
||||
$PF verify --root "$WORK/src" --lock "$WORK/lock1.json" > "$WORK/ver.log" 2>&1
|
||||
rc=$?
|
||||
[ $rc -ne 0 ] && ok "verify fails (nonzero) after a content edit" || bad "verify returned 0 on changed file"
|
||||
grep -q 'CHANGED pkgA/a.py' "$WORK/ver.log" && ok "verify names the changed file" || bad "changed file not reported"
|
||||
printf 'alpha\n' > "$WORK/src/pkgA/a.py"
|
||||
|
||||
# ---- 5. deletion is detected --------------------------------------------
|
||||
rm "$WORK/src/pkgB/c.py"
|
||||
$PF verify --root "$WORK/src" --lock "$WORK/lock1.json" > "$WORK/ver2.log" 2>&1
|
||||
rc=$?
|
||||
[ $rc -ne 0 ] && ok "verify fails after a deletion" || bad "verify returned 0 on deleted file"
|
||||
grep -q 'MISSING pkgB/c.py' "$WORK/ver2.log" && ok "verify names the missing file" || bad "missing file not reported"
|
||||
printf 'gamma' > "$WORK/src/pkgB/c.py"
|
||||
|
||||
# ---- 6. added file is detected ------------------------------------------
|
||||
printf 'new\n' > "$WORK/src/pkgB/d.py"
|
||||
$PF verify --root "$WORK/src" --lock "$WORK/lock1.json" > "$WORK/ver3.log" 2>&1
|
||||
rc=$?
|
||||
[ $rc -ne 0 ] && ok "verify fails after an added file" || bad "verify returned 0 on added file"
|
||||
grep -q 'ADDED pkgB/d.py' "$WORK/ver3.log" && ok "verify names the added file" || bad "added file not reported"
|
||||
rm "$WORK/src/pkgB/d.py"
|
||||
|
||||
# ---- 7. clean tree verifies OK ------------------------------------------
|
||||
$PF verify --root "$WORK/src" --lock "$WORK/lock1.json" > "$WORK/ver4.log" 2>&1
|
||||
rc=$?
|
||||
[ $rc -eq 0 ] && ok "verify passes on an untouched tree" || { bad "verify failed on clean tree"; cat "$WORK/ver4.log"; }
|
||||
grep -q 'OK: ' "$WORK/ver4.log" && ok "verify prints an OK line" || bad "no OK line"
|
||||
|
||||
# ---- 8. mtime-only change must NOT alter the lock -----------------------
|
||||
BEFORE=$(sha256sum "$WORK/lock1.json" | cut -d' ' -f1)
|
||||
sleep 1
|
||||
touch "$WORK/src/pkgA/a.py" "$WORK/src/README.md"
|
||||
$PF make --root "$WORK/src" --out "$WORK/lock3.json" > /dev/null 2>&1
|
||||
AFTER=$(sha256sum "$WORK/lock3.json" | cut -d' ' -f1)
|
||||
[ "$BEFORE" = "$AFTER" ] && ok "touching files (mtime only) leaves the lock identical" || bad "lock changed on mtime-only touch"
|
||||
|
||||
# ---- 9. symlinks and VCS/cache dirs are excluded ------------------------
|
||||
python3 - "$WORK/lock1.json" <<'PY' && ok "symlinks, .git and __pycache__ are excluded from the lock" || bad "unexpected entries in lock"
|
||||
import json, sys
|
||||
files = set(json.load(open(sys.argv[1]))["files"])
|
||||
assert not any(f.startswith(".git/") for f in files), "git dir leaked in"
|
||||
assert not any("__pycache__" in f for f in files), "pycache leaked in"
|
||||
assert "link.py" not in files, "symlink was followed and locked"
|
||||
assert "pkgA/a.py" in files and "README.md" in files, "real files missing"
|
||||
PY
|
||||
|
||||
# ---- 10. package digests partition the tree ------------------------------
|
||||
python3 - "$WORK/lock1.json" <<'PY' && ok "package digests cover every locked file exactly once" || bad "package partition wrong"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
seen = {}
|
||||
for pkg, items in []:
|
||||
pass
|
||||
# rebuild partition from files list via the documented rule
|
||||
files = d["files"]
|
||||
pkgs = {}
|
||||
for rel in files:
|
||||
name = rel.split("/", 1)[0] if "/" in rel else "(root)"
|
||||
pkgs.setdefault(name, []).append(rel)
|
||||
assert set(pkgs) == set(d["packages"]), (set(pkgs), set(d["packages"]))
|
||||
assert sum(len(v) for v in pkgs.values()) == len(files), "files double-counted"
|
||||
PY
|
||||
|
||||
# ---- 11. --include filter ------------------------------------------------
|
||||
$PF make --root "$WORK/src" --out "$WORK/lockf.json" --include 'pkgA/*' > /dev/null 2>&1
|
||||
python3 - "$WORK/lockf.json" <<'PY' && ok "--include restricts the lock to matching paths" || bad "--include filter broken"
|
||||
import json, sys
|
||||
files = json.load(open(sys.argv[1]))["files"]
|
||||
assert files and all(f.startswith("pkgA/") for f in files), files
|
||||
assert "pkgA/a.py" in files and "README.md" not in files, files
|
||||
PY
|
||||
|
||||
# ---- 12. check writes a machine-readable report --------------------------
|
||||
$PF check --root "$WORK/src" --lock "$WORK/lock1.json" --output "$WORK/report.json" > "$WORK/chk.log" 2>&1
|
||||
rc=$?
|
||||
[ $rc -eq 0 ] && ok "check exits 0 on a clean tree" || bad "check exit=$rc on clean tree"
|
||||
python3 - "$WORK/report.json" <<'PY' && ok "check report is valid JSON with ok=true and empty diffs" || bad "report shape wrong"
|
||||
import json, sys
|
||||
r = json.load(open(sys.argv[1]))
|
||||
assert r["ok"] is True, r
|
||||
assert r["missing"] == [] and r["changed"] == [] and r["added"] == [] and r["stale"] == [], r
|
||||
assert r["locked_tree_digest"] == r["observed_tree_digest"], r
|
||||
assert r["file_count"] > 0, r
|
||||
PY
|
||||
|
||||
printf '\n%d failure(s)\n' "$fails"
|
||||
[ "$fails" -eq 0 ]
|
||||
Reference in new issue
Block a user