Files
MeshCore/tools/bible/pack_reader_stream.py
T

150 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Pack the unchanged Reader source for a 4 KiB sliding-window nRF52 decoder."""
import argparse
import hashlib
from functools import lru_cache
import shutil
import subprocess
import tempfile
import json
from pathlib import Path
import re
import zlib
import pack_reader as blocks
HEADER = blocks.ROOT / "src/helpers/bible/ReaderStreamData.generated.h"
COMPACT_HEADER = blocks.ROOT / "src/helpers/bible/ReaderCompactStreamData.generated.h"
TOKENS = Path(__file__).with_name("reader-stream-tokens.json")
COMPRESSOR = Path(__file__).with_name("zopfli4k")
STREAM_LIMIT = 17000
COMPACT_STREAM_LIMIT = 60000
@lru_cache(maxsize=1)
def host_compressor():
compiler = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
if not compiler:
raise RuntimeError("Regenerating the nRF52 Reader asset requires a host C compiler")
directory = tempfile.TemporaryDirectory(prefix="mesh-reader-zopfli4k-")
binary = Path(directory.name) / "pack4k"
subprocess.run([compiler, "-std=c11", "-O2", "-DNDEBUG", "-I" + str(COMPRESSOR),
*map(str, sorted(COMPRESSOR.glob("*.c"))), "-lm", "-o", str(binary)], check=True)
return directory, binary
@lru_cache(maxsize=16)
def compress_4k(data):
directory, binary = host_compressor()
source = Path(directory.name) / "input"
target = Path(directory.name) / "output"
source.write_bytes(data)
subprocess.run([str(binary), str(source), str(target), "1000"], check=True, capture_output=True)
packed = target.read_bytes()
assert zlib.decompress(packed, wbits=-12) == data
return packed
def token_dictionary(verses):
# The vocabulary is an independently tunable compression asset. Tokens
# only substitute exact source bytes, including matches inside a word.
selected = json.loads(TOKENS.read_text(encoding="ascii"))
if len(selected) > 128 or len(set(selected)) != len(selected):
raise ValueError("Reader vocabulary must contain at most 128 unique byte strings")
words = [word.encode("ascii") for word in selected]
if any(not word or any(byte < 32 or byte >= 127 for byte in word) for word in words):
raise ValueError("Reader tokens must be nonempty printable ASCII")
return [word for word in words if any(word in verse for verse in verses)]
def encode(document, *, compact=False):
verses = blocks.source_verses(document)
words = token_dictionary(verses)
tokens = {word: bytes([128 + index]) for index, word in enumerate(words)}
# Tokens are exact byte strings, including any leading space. Matching
# inside another word is lossless too: expansion restores those same bytes.
pattern = re.compile(b"|".join(re.escape(word) for word in sorted(words, key=lambda x: (-len(x), x)))) if words else None
encoded_verses = []
dictionary_size = sum(map(len, words)) + len(words)
for verse in verses:
encoded = pattern.sub(lambda match: tokens[match.group()], verse) if pattern else verse
if encoded != verse and len(encoded) + len(verse) + dictionary_size + 2 > blocks.BLOCK_SIZE:
encoded = verse # Literal output needs no dictionary workspace.
encoded_verses.append(encoded)
records = [verse + b"\0" for verse in encoded_verses]
chunks, first, current = [], 0, b""
for index, record in enumerate(records):
if current and len(current) + len(record) > (COMPACT_STREAM_LIMIT if compact else STREAM_LIMIT):
chunks.append((first, current))
first, current = index, b""
current += record
chunks.append((first, current))
# A single stream needs no framing. Larger corpora retain independently
# bounded 12-bit histories and six-byte headers, without another buffer.
if compact and len(chunks) == 1:
return compress_4k(chunks[0][1]), b"".join(records), words, 0
packed = bytearray()
for first, plain in chunks:
compressed = compress_4k(plain)
packed += (first.to_bytes(2, "little") + len(plain).to_bytes(2, "little")
+ len(compressed).to_bytes(2, "little") + compressed)
return bytes(packed), b"".join(records), words, len(chunks)
def render(document, *, compact=False):
packed, plain, words, chunk_count = encode(document, compact=compact)
dictionary_plain = b"\0".join(words) + b"\0" if words else b""
dictionary = compress_4k(dictionary_plain) if words else b""
digest = hashlib.sha256(json.dumps(document, ensure_ascii=False, sort_keys=True,
separators=(",", ":")).encode()).hexdigest()
command = "tools/bible/pack_reader_stream.py" + (" --compact" if compact else "")
lines = [f"// Generated by {command}; do not edit.",
"// Offline Reader corpus. Public domain; see tools/bible/README.md.",
"// Google Zopfli, 1000 iterations, 4096-byte history; exact ASCII text retained.",
f"// Canonical source SHA-256: {digest}",
"#pragma once", '#include "ReaderStreamLookup.h"',
"namespace mesh { namespace bible { namespace generated {",
"static_assert(kBlockSize == 4096, \"Reader history must remain 4096 bytes\");",
"static const uint8_t readerData[] = {"]
for start in range(0, len(packed), 20):
lines.append(" " + ",".join(f"0x{v:02x}" for v in packed[start:start + 20]) + ",")
lines += ["};", "static const uint8_t readerWords[] = {"]
for start in range(0, len(dictionary), 20):
lines.append(" " + ",".join(f"0x{v:02x}" for v in dictionary[start:start + 20]) + ",")
if not dictionary:
lines.append(" 0, // Empty vocabulary: literal records remain supported.")
lines += ["};", "static const StreamTokenCorpus readerCorpus = {",
f" {json.dumps(document['translation'])},",
f" {json.dumps(document['attribution'])},",
f" readerData, sizeof(readerData), {len(plain)},",
f" readerWords, {len(dictionary)}, {len(dictionary_plain)},",
f" {len(words)}, kVerseCount, {chunk_count}", "};", "}}}", ""]
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=blocks.SOURCE)
parser.add_argument("--output", type=Path)
parser.add_argument("--compact", action="store_true", help="RAK4631 Full: smaller asset with slower cold lookup")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
document = json.loads(args.source.read_text(encoding="utf-8"))
output = args.output or (COMPACT_HEADER if args.compact else HEADER)
header = render(document, compact=args.compact)
if args.check:
if output.read_text(encoding="ascii") != header:
raise SystemExit("Reader stream header differs; regenerate with tools/bible/pack_reader_stream.py")
else:
output.write_text(header, encoding="ascii", newline="\n")
packed, plain, words, chunk_count = encode(document, compact=args.compact)
dictionary_plain = b"\0".join(words) + b"\0" if words else b""
dictionary_size = len(compress_4k(dictionary_plain)) if words else 0
print(f"Reader stream: {len(blocks.references())} unchanged verses, "
f"{len(plain)} encoded bytes -> {len(packed)} DEFLATE + "
f"{dictionary_size} dictionary bytes; {chunk_count or 1} independent streams, 4096-byte history")
if __name__ == "__main__":
main()