Scaffolding: ZMK module structure, Kconfig, DTS, behavior driver
- zephyr/module.yml with dts_root for dt-bindings - Kconfig: STENO_ENGINE, dict selection (Plover/Lapwing/test), MPHF toggle, Unicode modes, history size, multi-stroke timeout - DTS behavior binding (one_param, steno key index) - behavior_steno.c: chord accumulation, all-up detection, multi-stroke buffering with timeout, star undo, formatter pipeline, 3-way dict dispatch (split/MPHF/simple trie) - dict_embed.S: .incbin from generated header path - steno_keys.h: 23-key layout, bit positions matching compiler
This commit is contained in:
parent
234bc8d731
commit
1ec204b845
33 changed files with 5443 additions and 0 deletions
553
tools/compile_mphf.py
Executable file
553
tools/compile_mphf.py
Executable file
|
|
@ -0,0 +1,553 @@
|
|||
#!/usr/bin/env python3
|
||||
"""CHD MPHF dictionary compiler for steno engine.
|
||||
|
||||
Reads Plover JSON dictionaries and produces a compact binary for
|
||||
embedded use (nRF52840, 462KB flash budget).
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
import math
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
import zlib
|
||||
from collections import defaultdict
|
||||
|
||||
# ─── Steno stroke parsing ───
|
||||
|
||||
STENO_KEYS = {
|
||||
'#': 0x00400000,
|
||||
'S-': 0x00000001, 'T-': 0x00000002, 'K-': 0x00000004,
|
||||
'P-': 0x00000008, 'W-': 0x00000010, 'H-': 0x00000020,
|
||||
'R-': 0x00000040, 'A-': 0x00000080, 'O-': 0x00000100,
|
||||
'*': 0x00000200, '-E': 0x00000400, '-U': 0x00000800,
|
||||
'-F': 0x00001000, '-R': 0x00002000, '-P': 0x00004000,
|
||||
'-B': 0x00008000, '-L': 0x00010000, '-G': 0x00020000,
|
||||
'-T': 0x00040000, '-S': 0x00080000, '-D': 0x00100000,
|
||||
'-Z': 0x00200000,
|
||||
}
|
||||
|
||||
IMPLICIT_HYPHEN = set('AOEU*')
|
||||
|
||||
|
||||
def parse_stroke(s):
|
||||
result = 0
|
||||
if '#' in s:
|
||||
result |= STENO_KEYS['#']
|
||||
s = s.replace('#', '')
|
||||
has_hyphen = '-' in s
|
||||
s_clean = s.replace('-', '')
|
||||
if not has_hyphen and not any(c in IMPLICIT_HYPHEN for c in s_clean):
|
||||
for c in s_clean:
|
||||
key = c + '-'
|
||||
if key in STENO_KEYS:
|
||||
result |= STENO_KEYS[key]
|
||||
return result
|
||||
if has_hyphen:
|
||||
hyphen_pos = s.index('-')
|
||||
for i, c in enumerate(s):
|
||||
if c == '-':
|
||||
continue
|
||||
if c in 'AO':
|
||||
result |= STENO_KEYS[c + '-']
|
||||
elif c in 'EU':
|
||||
result |= STENO_KEYS['-' + c]
|
||||
elif c == '*':
|
||||
result |= STENO_KEYS['*']
|
||||
elif i < hyphen_pos and (c + '-') in STENO_KEYS:
|
||||
result |= STENO_KEYS[c + '-']
|
||||
elif i > hyphen_pos and ('-' + c) in STENO_KEYS:
|
||||
result |= STENO_KEYS['-' + c]
|
||||
else:
|
||||
past_vowels = False
|
||||
for c in s_clean:
|
||||
if c in 'AO':
|
||||
result |= STENO_KEYS[c + '-']
|
||||
past_vowels = True
|
||||
elif c in 'EU':
|
||||
result |= STENO_KEYS['-' + c]
|
||||
past_vowels = True
|
||||
elif c == '*':
|
||||
result |= STENO_KEYS['*']
|
||||
past_vowels = True
|
||||
elif not past_vowels and (c + '-') in STENO_KEYS:
|
||||
result |= STENO_KEYS[c + '-']
|
||||
elif past_vowels and ('-' + c) in STENO_KEYS:
|
||||
result |= STENO_KEYS['-' + c]
|
||||
elif (c + '-') in STENO_KEYS:
|
||||
result |= STENO_KEYS[c + '-']
|
||||
return result
|
||||
|
||||
|
||||
# ─── Hashing ───
|
||||
|
||||
def fnv1a_32(data: bytes) -> int:
|
||||
"""FNV-1a 32-bit hash."""
|
||||
h = 0x811c9dc5
|
||||
for b in data:
|
||||
h ^= b
|
||||
h = (h * 0x01000193) & 0xFFFFFFFF
|
||||
return h
|
||||
|
||||
|
||||
def hash_key(key_bytes: bytes, seed: int) -> int:
|
||||
"""Hash key with seed by prepending seed bytes."""
|
||||
return fnv1a_32(struct.pack('<I', seed) + key_bytes)
|
||||
|
||||
|
||||
# ─── Bit packing ───
|
||||
|
||||
class BitWriter:
|
||||
def __init__(self):
|
||||
self.data = bytearray()
|
||||
self.bit_pos = 0 # total bits written
|
||||
|
||||
def write_bits(self, value, n_bits):
|
||||
"""Write n_bits of value (LSB first)."""
|
||||
for i in range(n_bits):
|
||||
if self.bit_pos % 8 == 0:
|
||||
self.data.append(0)
|
||||
if value & (1 << i):
|
||||
self.data[-1] |= (1 << (self.bit_pos % 8))
|
||||
self.bit_pos += 1
|
||||
|
||||
def pad_to_alignment(self, alignment=4):
|
||||
"""Pad to byte alignment."""
|
||||
while len(self.data) % alignment != 0:
|
||||
self.data.append(0)
|
||||
self.bit_pos = len(self.data) * 8
|
||||
|
||||
def to_bytes(self):
|
||||
return bytes(self.data)
|
||||
|
||||
|
||||
class BitReader:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.bit_pos = 0
|
||||
|
||||
def read_bits(self, n_bits):
|
||||
value = 0
|
||||
for i in range(n_bits):
|
||||
byte_idx = self.bit_pos // 8
|
||||
bit_idx = self.bit_pos % 8
|
||||
if self.data[byte_idx] & (1 << bit_idx):
|
||||
value |= (1 << i)
|
||||
self.bit_pos += 1
|
||||
return value
|
||||
|
||||
|
||||
# ─── Key encoding ───
|
||||
|
||||
def encode_key(stroke_str):
|
||||
"""Parse stroke string → key_bytes (each stroke as u32 LE, concatenated)."""
|
||||
parts = stroke_str.split('/')
|
||||
strokes = tuple(parse_stroke(s) for s in parts)
|
||||
key_bytes = b''.join(struct.pack('<I', s) for s in strokes)
|
||||
return strokes, key_bytes
|
||||
|
||||
|
||||
# ─── Importance scoring ───
|
||||
|
||||
def score_entry(stroke_str, translation):
|
||||
"""Lower score = more important = keep first."""
|
||||
n_strokes = stroke_str.count('/') + 1
|
||||
has_format = '{' in translation
|
||||
return (n_strokes, has_format, len(translation), stroke_str)
|
||||
|
||||
|
||||
# ─── CHD MPHF construction ───
|
||||
|
||||
def build_chd(keys_and_bytes, entry_count):
|
||||
"""
|
||||
Build CHD MPHF.
|
||||
|
||||
keys_and_bytes: list of (index, key_bytes) for each entry
|
||||
entry_count: total number of entries
|
||||
|
||||
Returns: (displacements, slot_to_entry_idx, max_displacement)
|
||||
displacements[bucket] = d value
|
||||
slot_to_entry_idx[slot] = index into keys_and_bytes, or -1 if empty
|
||||
"""
|
||||
bucket_count = max(entry_count // 3, min(entry_count, 16))
|
||||
|
||||
# Assign keys to buckets
|
||||
buckets = defaultdict(list)
|
||||
for idx, (_, kb) in enumerate(keys_and_bytes):
|
||||
b = hash_key(kb, 0) % bucket_count
|
||||
buckets[b].append(idx)
|
||||
|
||||
# Sort buckets by size descending
|
||||
sorted_buckets = sorted(buckets.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
displacements = [0] * bucket_count
|
||||
occupied = set()
|
||||
slot_to_entry = [-1] * entry_count
|
||||
max_disp = 0
|
||||
|
||||
for bucket_id, members in sorted_buckets:
|
||||
if not members:
|
||||
continue
|
||||
|
||||
member_key_bytes = [(m, keys_and_bytes[m][1]) for m in members]
|
||||
|
||||
placed = False
|
||||
for d in range(65536):
|
||||
slots = []
|
||||
collision = False
|
||||
seen = set()
|
||||
|
||||
for _, kb in member_key_bytes:
|
||||
slot = hash_key(kb, d + 1) % entry_count
|
||||
if slot in occupied or slot in seen:
|
||||
collision = True
|
||||
break
|
||||
seen.add(slot)
|
||||
slots.append(slot)
|
||||
|
||||
if collision:
|
||||
continue
|
||||
|
||||
# Place all members
|
||||
for i, (m, _) in enumerate(member_key_bytes):
|
||||
occupied.add(slots[i])
|
||||
slot_to_entry[slots[i]] = m
|
||||
|
||||
displacements[bucket_id] = d
|
||||
if d > max_disp:
|
||||
max_disp = d
|
||||
placed = True
|
||||
break
|
||||
|
||||
if not placed:
|
||||
print(f"FATAL: bucket {bucket_id} with {len(members)} keys failed after 65536 tries",
|
||||
file=sys.stderr)
|
||||
return None, None, None
|
||||
|
||||
return displacements, slot_to_entry, max_disp
|
||||
|
||||
|
||||
# ─── Compilation ───
|
||||
|
||||
def compile_mphf(entries, max_size=None):
|
||||
"""
|
||||
entries: list of (stroke_str, translation) from JSON dict
|
||||
max_size: max output size in bytes (default: 462*1024 = 473088)
|
||||
|
||||
Returns: bytes (the compiled binary) or None if can't fit
|
||||
"""
|
||||
if max_size is None:
|
||||
max_size = 462 * 1024
|
||||
|
||||
# Sort by importance for potential trimming
|
||||
entries_scored = sorted(entries, key=lambda e: score_entry(e[0], e[1]))
|
||||
|
||||
# Parse all keys, dedup by key_bytes (last wins for same key)
|
||||
seen_keys = {}
|
||||
for stroke_str, translation in entries_scored:
|
||||
strokes, key_bytes = encode_key(stroke_str)
|
||||
if key_bytes in seen_keys:
|
||||
prev = seen_keys[key_bytes]
|
||||
print(f" Dedup: '{stroke_str}'→'{translation}' collides with "
|
||||
f"'{prev[2]}'→'{prev[1]}', keeping first", file=sys.stderr)
|
||||
continue
|
||||
entry = (key_bytes, translation, stroke_str, strokes)
|
||||
seen_keys[key_bytes] = entry
|
||||
|
||||
keys_and_bytes = list(seen_keys.values())
|
||||
|
||||
# Iteratively trim if needed
|
||||
while True:
|
||||
entry_count = len(keys_and_bytes)
|
||||
if entry_count == 0:
|
||||
return None
|
||||
|
||||
# Build deduped string table
|
||||
translations = [kb[1] for kb in keys_and_bytes]
|
||||
unique_translations = sorted(set(translations))
|
||||
trans_to_id = {t: i for i, t in enumerate(unique_translations)}
|
||||
unique_count = len(unique_translations)
|
||||
|
||||
# Estimate size
|
||||
bucket_count = max(entry_count // 3, min(entry_count, 16))
|
||||
est_value_bits = max(1, math.ceil(math.log2(max(unique_count, 2))))
|
||||
est_disp_bits = 16 # conservative
|
||||
est_disp_bytes = (bucket_count * est_disp_bits + 7) // 8
|
||||
est_disp_bytes = ((est_disp_bytes + 3) // 4) * 4
|
||||
est_value_bytes = (entry_count * est_value_bits + 7) // 8
|
||||
est_value_bytes = ((est_value_bytes + 3) // 4) * 4
|
||||
est_fp_bytes = ((entry_count + 3) // 4) * 4
|
||||
|
||||
# String table (block-compressed)
|
||||
string_data_raw = b''
|
||||
string_offsets = []
|
||||
for t in unique_translations:
|
||||
string_offsets.append(len(string_data_raw))
|
||||
string_data_raw += t.encode('utf-8') + b'\x00'
|
||||
|
||||
block_size = 4096
|
||||
compressed_blocks = []
|
||||
for i in range(0, len(string_data_raw), block_size):
|
||||
block = string_data_raw[i:i + block_size]
|
||||
compressed_blocks.append(zlib.compress(block, 9))
|
||||
|
||||
est_str_offsets = unique_count * 3 # u24 packed LE
|
||||
est_block_dir = 2 + len(compressed_blocks) * 4 # u16 count + u32 offsets
|
||||
est_str_data = sum(len(b) for b in compressed_blocks) + est_block_dir
|
||||
|
||||
# Prefix table
|
||||
prefix_strokes = set()
|
||||
for kb, trans, stroke_str, strokes in keys_and_bytes:
|
||||
if len(strokes) > 1:
|
||||
prefix_strokes.add(strokes[0])
|
||||
prefix_list = sorted(prefix_strokes)
|
||||
est_prefix = len(prefix_list) * 4
|
||||
|
||||
total_est = 32 + est_disp_bytes + est_value_bytes + est_fp_bytes + est_str_offsets + est_str_data + est_prefix
|
||||
|
||||
if total_est <= max_size:
|
||||
break
|
||||
|
||||
# Trim: use ratio of overshoot to estimate how many entries to cut
|
||||
overshoot_ratio = total_est / max_size
|
||||
target_entries = int(entry_count / overshoot_ratio * 0.98) # 2% safety margin
|
||||
trim_count = max(1, entry_count - target_entries)
|
||||
keys_and_bytes = keys_and_bytes[:entry_count - trim_count]
|
||||
print(f" Trimming to {len(keys_and_bytes)} entries (est {total_est} > {max_size})",
|
||||
file=sys.stderr)
|
||||
|
||||
entry_count = len(keys_and_bytes)
|
||||
bucket_count = max(entry_count // 3, min(entry_count, 16))
|
||||
|
||||
print(f" Building CHD MPHF: {entry_count} entries, {bucket_count} buckets...",
|
||||
file=sys.stderr)
|
||||
|
||||
# Build CHD
|
||||
chd_input = [(i, keys_and_bytes[i][0]) for i in range(entry_count)]
|
||||
displacements, slot_to_entry, max_disp = build_chd(chd_input, entry_count)
|
||||
|
||||
if displacements is None:
|
||||
return None
|
||||
|
||||
# Compute actual bit widths
|
||||
disp_bits = max(1, math.ceil(math.log2(max(max_disp + 1, 2))))
|
||||
value_bits = max(1, math.ceil(math.log2(max(unique_count, 2))))
|
||||
prefix_count = len(prefix_list)
|
||||
|
||||
print(f" Max displacement: {max_disp}, disp_bits: {disp_bits}", file=sys.stderr)
|
||||
print(f" Unique translations: {unique_count}, value_bits: {value_bits}", file=sys.stderr)
|
||||
print(f" Prefix entries: {prefix_count}", file=sys.stderr)
|
||||
|
||||
# ─── Build binary ───
|
||||
|
||||
# Displacements section
|
||||
disp_writer = BitWriter()
|
||||
for d in displacements:
|
||||
disp_writer.write_bits(d, disp_bits)
|
||||
disp_writer.pad_to_alignment(4)
|
||||
disp_section = disp_writer.to_bytes()
|
||||
|
||||
# Values section: slot → value_id
|
||||
val_writer = BitWriter()
|
||||
fingerprints = bytearray(entry_count)
|
||||
for slot in range(entry_count):
|
||||
entry_idx = slot_to_entry[slot]
|
||||
if entry_idx >= 0:
|
||||
kb, trans, stroke_str, strokes = keys_and_bytes[entry_idx]
|
||||
val_id = trans_to_id[trans]
|
||||
val_writer.write_bits(val_id, value_bits)
|
||||
fingerprints[slot] = fnv1a_32(kb) & 0xFF
|
||||
else:
|
||||
val_writer.write_bits(0, value_bits)
|
||||
fingerprints[slot] = 0
|
||||
val_writer.pad_to_alignment(4)
|
||||
val_section = val_writer.to_bytes()
|
||||
|
||||
# Fingerprints section
|
||||
fp_section = bytes(fingerprints)
|
||||
# Pad to 4-byte boundary
|
||||
while len(fp_section) % 4 != 0:
|
||||
fp_section += b'\x00'
|
||||
|
||||
# String offsets section (u24 packed LE — 3 bytes each, into raw/uncompressed table)
|
||||
str_offsets_section = b''.join(struct.pack('<I', off)[:3] for off in string_offsets)
|
||||
|
||||
# String data section (block-compressed)
|
||||
block_dir = struct.pack('<H', len(compressed_blocks))
|
||||
block_offset = 0
|
||||
for blk in compressed_blocks:
|
||||
block_dir += struct.pack('<I', block_offset)
|
||||
block_offset += len(blk)
|
||||
str_data_section = block_dir + b''.join(compressed_blocks)
|
||||
|
||||
# Prefix table section
|
||||
prefix_section = b''.join(struct.pack('<I', s) for s in prefix_list)
|
||||
|
||||
# Header (32 bytes):
|
||||
# magic: u32, version: u16, flags: u16,
|
||||
# entry_count: u32, bucket_count: u32, unique_count: u32,
|
||||
# value_bits: u8, disp_bits: u8, prefix_count: u16,
|
||||
# reserved0: u32, reserved1: u32
|
||||
header = struct.pack('<IHHIIIBBHii',
|
||||
0x4F4E5453, # magic "STNO"
|
||||
2, # version
|
||||
0x0001, # flags: bit 0 = block-compressed strings
|
||||
entry_count, # entry_count
|
||||
bucket_count, # bucket_count
|
||||
unique_count, # unique_count
|
||||
value_bits, # value_bits
|
||||
disp_bits, # disp_bits
|
||||
prefix_count, # prefix_count
|
||||
0, # reserved0
|
||||
0, # reserved1
|
||||
)
|
||||
assert len(header) == 32, f"Header is {len(header)} bytes, expected 32"
|
||||
|
||||
binary = header + disp_section + val_section + fp_section + str_offsets_section + str_data_section + prefix_section
|
||||
|
||||
# ─── Verification ───
|
||||
print(f" Verifying all {entry_count} entries...", file=sys.stderr)
|
||||
errors = 0
|
||||
for entry_idx in range(entry_count):
|
||||
kb, trans, stroke_str, strokes = keys_and_bytes[entry_idx]
|
||||
|
||||
# Lookup through MPHF
|
||||
bucket = hash_key(kb, 0) % bucket_count
|
||||
|
||||
# Read displacement
|
||||
disp_reader = BitReader(disp_section)
|
||||
disp_reader.bit_pos = bucket * disp_bits
|
||||
d = disp_reader.read_bits(disp_bits)
|
||||
|
||||
slot = hash_key(kb, d + 1) % entry_count
|
||||
|
||||
# Check fingerprint
|
||||
expected_fp = fnv1a_32(kb) & 0xFF
|
||||
if fingerprints[slot] != expected_fp:
|
||||
print(f" VERIFY FAIL: fingerprint mismatch for '{stroke_str}' at slot {slot}: "
|
||||
f"got {fingerprints[slot]}, expected {expected_fp}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# Check value
|
||||
val_reader = BitReader(val_section)
|
||||
val_reader.bit_pos = slot * value_bits
|
||||
val_id = val_reader.read_bits(value_bits)
|
||||
|
||||
# Resolve string from compressed table
|
||||
off_bytes = str_offsets_section[val_id * 3:(val_id + 1) * 3]
|
||||
str_off = off_bytes[0] | (off_bytes[1] << 8) | (off_bytes[2] << 16)
|
||||
block_idx = str_off // block_size
|
||||
in_block_off = str_off % block_size
|
||||
raw_block = zlib.decompress(compressed_blocks[block_idx])
|
||||
if b'\x00' in raw_block[in_block_off:]:
|
||||
end = raw_block.index(b'\x00', in_block_off)
|
||||
resolved = raw_block[in_block_off:end].decode('utf-8')
|
||||
elif block_idx + 1 < len(compressed_blocks):
|
||||
part1 = raw_block[in_block_off:]
|
||||
next_block = zlib.decompress(compressed_blocks[block_idx + 1])
|
||||
end = next_block.index(b'\x00')
|
||||
resolved = (part1 + next_block[:end]).decode('utf-8')
|
||||
else:
|
||||
resolved = raw_block[in_block_off:].decode('utf-8')
|
||||
|
||||
if resolved != trans:
|
||||
print(f" VERIFY FAIL: value mismatch for '{stroke_str}': "
|
||||
f"got '{resolved}', expected '{trans}'", file=sys.stderr)
|
||||
errors += 1
|
||||
|
||||
if errors:
|
||||
print(f" VERIFICATION FAILED: {errors} errors", file=sys.stderr)
|
||||
return None
|
||||
|
||||
print(f" Verification passed: all {entry_count} entries OK", file=sys.stderr)
|
||||
|
||||
# Check final size
|
||||
if len(binary) > max_size:
|
||||
print(f" WARNING: output {len(binary)} bytes exceeds max {max_size}", file=sys.stderr)
|
||||
|
||||
return binary, {
|
||||
'entry_count': entry_count,
|
||||
'bucket_count': bucket_count,
|
||||
'unique_count': unique_count,
|
||||
'value_bits': value_bits,
|
||||
'disp_bits': disp_bits,
|
||||
'max_displacement': max_disp,
|
||||
'prefix_count': prefix_count,
|
||||
'disp_section_bytes': len(disp_section),
|
||||
'val_section_bytes': len(val_section),
|
||||
'fp_section_bytes': len(fp_section),
|
||||
'str_offsets_bytes': len(str_offsets_section),
|
||||
'str_data_bytes': len(str_data_section),
|
||||
'str_data_raw_bytes': len(string_data_raw),
|
||||
'prefix_section_bytes': len(prefix_section),
|
||||
'total_bytes': len(binary),
|
||||
}
|
||||
|
||||
|
||||
def print_stats(stats):
|
||||
"""Print size breakdown statistics."""
|
||||
total = stats['entry_count']
|
||||
print(f"Entries: {stats['entry_count']}")
|
||||
print(f"MPHF displacements: {stats['disp_section_bytes']/1024:.1f} KB "
|
||||
f"({stats['bucket_count']} buckets, {stats['disp_bits']} bits each)")
|
||||
print(f"Value array: {stats['val_section_bytes']/1024:.1f} KB "
|
||||
f"({stats['entry_count']} entries, {stats['value_bits']} bits each)")
|
||||
print(f"Fingerprints: {stats['fp_section_bytes']/1024:.1f} KB")
|
||||
print(f"String offsets: {stats['str_offsets_bytes']/1024:.1f} KB "
|
||||
f"({stats['unique_count']} unique x 3 bytes)")
|
||||
print(f"String data: {stats['str_data_bytes']/1024:.1f} KB"
|
||||
f" (compressed, {stats.get('str_data_raw_bytes', 0)/1024:.1f} KB raw)")
|
||||
print(f"Prefix table: {stats['prefix_section_bytes']/1024:.1f} KB "
|
||||
f"({stats['prefix_count']} entries x 4 bytes)")
|
||||
print(f"Total: {stats['total_bytes']/1024:.1f} KB")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Compile steno dictionary to MPHF binary format')
|
||||
parser.add_argument('input', help='Input JSON dictionary (Plover format)')
|
||||
parser.add_argument('output', help='Output binary file')
|
||||
parser.add_argument('--max-size', type=int, default=462*1024,
|
||||
help='Maximum output size in bytes (default: 473088 = 462KB)')
|
||||
parser.add_argument('--max-entries', type=int, default=None,
|
||||
help='Maximum number of entries')
|
||||
parser.add_argument('--stats', action='store_true',
|
||||
help='Print size breakdown statistics')
|
||||
parser.add_argument('--verify', action='store_true', default=True,
|
||||
help='Verify compiled dict (default: true)')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load dictionary
|
||||
with open(args.input) as f:
|
||||
raw_dict = json.load(f)
|
||||
|
||||
print(f"Loaded {len(raw_dict)} entries from {args.input}", file=sys.stderr)
|
||||
|
||||
entries = list(raw_dict.items())
|
||||
|
||||
if args.max_entries is not None:
|
||||
entries_scored = sorted(entries, key=lambda e: score_entry(e[0], e[1]))
|
||||
entries = entries_scored[:args.max_entries]
|
||||
print(f"Trimmed to {len(entries)} entries (--max-entries)", file=sys.stderr)
|
||||
|
||||
result = compile_mphf(entries, max_size=args.max_size)
|
||||
|
||||
if result is None:
|
||||
print("Compilation failed", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
binary, stats = result
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(binary)
|
||||
|
||||
print(f"Wrote {len(binary)} bytes to {args.output}", file=sys.stderr)
|
||||
|
||||
if args.stats:
|
||||
print()
|
||||
print_stats(stats)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
184
tools/compile_simple.py
Normal file
184
tools/compile_simple.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Simple flat-format steno dictionary compiler.
|
||||
|
||||
Outputs a binary format optimized for binary search on embedded targets.
|
||||
No compression — just sorted entries with fixed-width keys.
|
||||
|
||||
Format:
|
||||
Header (16 bytes):
|
||||
magic: u32 = 0x4F4E5453 ("STNO")
|
||||
version: u16 = 1
|
||||
max_strokes: u8 (max stroke count per entry)
|
||||
pad: u8
|
||||
entry_count: u32
|
||||
strings_offset: u32
|
||||
|
||||
Entry array (sorted by stroke tuple, fixed width):
|
||||
Each entry = max_strokes * 4 + 4 bytes:
|
||||
strokes[max_strokes]: u32 LE (unused slots = 0)
|
||||
string_offset: u32 LE (into string table)
|
||||
|
||||
String table:
|
||||
Null-terminated UTF-8 strings, concatenated
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
|
||||
STENO_KEYS = {
|
||||
'#': 0x00400000,
|
||||
'S-': 0x00000001, 'T-': 0x00000002, 'K-': 0x00000004,
|
||||
'P-': 0x00000008, 'W-': 0x00000010, 'H-': 0x00000020,
|
||||
'R-': 0x00000040, 'A-': 0x00000080, 'O-': 0x00000100,
|
||||
'*': 0x00000200, '-E': 0x00000400, '-U': 0x00000800,
|
||||
'-F': 0x00001000, '-R': 0x00002000, '-P': 0x00004000,
|
||||
'-B': 0x00008000, '-L': 0x00010000, '-G': 0x00020000,
|
||||
'-T': 0x00040000, '-S': 0x00080000, '-D': 0x00100000,
|
||||
'-Z': 0x00200000,
|
||||
}
|
||||
IMPLICIT_HYPHEN = set('AOEU*')
|
||||
|
||||
|
||||
def parse_stroke(s):
|
||||
result = 0
|
||||
if '#' in s:
|
||||
result |= STENO_KEYS['#']
|
||||
s = s.replace('#', '')
|
||||
has_hyphen = '-' in s
|
||||
s_clean = s.replace('-', '')
|
||||
if not has_hyphen and not any(c in IMPLICIT_HYPHEN for c in s_clean):
|
||||
for c in s_clean:
|
||||
key = c + '-'
|
||||
if key in STENO_KEYS:
|
||||
result |= STENO_KEYS[key]
|
||||
return result
|
||||
if has_hyphen:
|
||||
hyphen_pos = s.index('-')
|
||||
for i, c in enumerate(s):
|
||||
if c == '-':
|
||||
continue
|
||||
if c in 'AO':
|
||||
result |= STENO_KEYS[c + '-']
|
||||
elif c in 'EU':
|
||||
result |= STENO_KEYS['-' + c]
|
||||
elif c == '*':
|
||||
result |= STENO_KEYS['*']
|
||||
elif i < hyphen_pos and (c + '-') in STENO_KEYS:
|
||||
result |= STENO_KEYS[c + '-']
|
||||
elif i > hyphen_pos and ('-' + c) in STENO_KEYS:
|
||||
result |= STENO_KEYS['-' + c]
|
||||
else:
|
||||
past_vowels = False
|
||||
for c in s_clean:
|
||||
if c in 'AO':
|
||||
result |= STENO_KEYS[c + '-']
|
||||
past_vowels = True
|
||||
elif c in 'EU':
|
||||
result |= STENO_KEYS['-' + c]
|
||||
past_vowels = True
|
||||
elif c == '*':
|
||||
result |= STENO_KEYS['*']
|
||||
past_vowels = True
|
||||
elif not past_vowels and (c + '-') in STENO_KEYS:
|
||||
result |= STENO_KEYS[c + '-']
|
||||
elif past_vowels and ('-' + c) in STENO_KEYS:
|
||||
result |= STENO_KEYS['-' + c]
|
||||
elif (c + '-') in STENO_KEYS:
|
||||
result |= STENO_KEYS[c + '-']
|
||||
return result
|
||||
|
||||
|
||||
def compile_dict(json_path, max_entries=None, max_strokes_cap=None):
|
||||
with open(json_path) as f:
|
||||
raw = json.load(f)
|
||||
|
||||
entries = []
|
||||
for stroke_str, translation in raw.items():
|
||||
strokes = tuple(parse_stroke(s) for s in stroke_str.split('/'))
|
||||
if max_strokes_cap and len(strokes) > max_strokes_cap:
|
||||
continue
|
||||
entries.append((strokes, translation))
|
||||
|
||||
if max_entries and len(entries) > max_entries:
|
||||
single = [(s, t) for s, t in entries if len(s) == 1]
|
||||
multi = [(s, t) for s, t in entries if len(s) > 1]
|
||||
multi.sort(key=lambda x: (len(x[1]), len(x[0])))
|
||||
remaining = max_entries - len(single)
|
||||
if remaining < 0:
|
||||
single.sort(key=lambda x: len(x[1]))
|
||||
entries = single[:max_entries]
|
||||
else:
|
||||
entries = single + multi[:remaining]
|
||||
|
||||
entries.sort(key=lambda x: x[0])
|
||||
max_strokes = max(len(s) for s, _ in entries)
|
||||
|
||||
string_table = bytearray()
|
||||
string_offsets = {}
|
||||
for _, translation in entries:
|
||||
if translation not in string_offsets:
|
||||
string_offsets[translation] = len(string_table)
|
||||
string_table.extend(translation.encode('utf-8'))
|
||||
string_table.append(0)
|
||||
|
||||
entry_size = max_strokes * 4 + 4
|
||||
header_size = 16
|
||||
entries_size = len(entries) * entry_size
|
||||
strings_offset = header_size + entries_size
|
||||
|
||||
header = struct.pack('<IHBBII',
|
||||
0x4F4E5453, # "STNO"
|
||||
1, # version
|
||||
max_strokes,
|
||||
0, # pad
|
||||
len(entries),
|
||||
strings_offset)
|
||||
|
||||
entry_data = bytearray()
|
||||
for strokes, translation in entries:
|
||||
padded = list(strokes) + [0] * (max_strokes - len(strokes))
|
||||
for s in padded:
|
||||
entry_data.extend(struct.pack('<I', s))
|
||||
entry_data.extend(struct.pack('<I', string_offsets[translation]))
|
||||
|
||||
binary = header + bytes(entry_data) + bytes(string_table)
|
||||
return binary, len(entries), max_strokes, len(string_table)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Compile steno dict to flat binary')
|
||||
parser.add_argument('input', help='JSON dictionary path')
|
||||
parser.add_argument('-o', '--output', default='steno_dict.bin')
|
||||
parser.add_argument('--max-entries', type=int, default=None)
|
||||
parser.add_argument('--max-strokes', type=int, default=None,
|
||||
help='Cap max stroke count per entry (drop longer)')
|
||||
parser.add_argument('--stats', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.input):
|
||||
print(f"ERROR: {args.input} not found")
|
||||
sys.exit(1)
|
||||
|
||||
binary, n_entries, max_strokes, str_size = compile_dict(
|
||||
args.input, args.max_entries, args.max_strokes)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(binary)
|
||||
|
||||
if args.stats:
|
||||
entry_size = max_strokes * 4 + 4
|
||||
print(f"Entries: {n_entries}")
|
||||
print(f"Max strokes: {max_strokes}")
|
||||
print(f"Entry size: {entry_size} bytes")
|
||||
print(f"Entry array: {n_entries * entry_size} bytes")
|
||||
print(f"String table: {str_size} bytes")
|
||||
print(f"Total: {len(binary)} bytes ({len(binary)/1024:.1f} KB)")
|
||||
|
||||
print(f"Written {len(binary)} bytes to {args.output}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
815
tools/dict_compiler.py
Normal file
815
tools/dict_compiler.py
Normal file
|
|
@ -0,0 +1,815 @@
|
|||
#!/usr/bin/env python3
|
||||
"""DAWG dictionary compiler for ZMK steno engine.
|
||||
|
||||
Compiles a Plover-format JSON steno dictionary into a compact binary
|
||||
DAWG with skip-count indexing and block-compressed string table.
|
||||
|
||||
Optimize-dict variant targeting 462KB (left half flash budget).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from collections import Counter
|
||||
|
||||
# ─── Steno stroke parsing (copied from dawg_fst_prototype.py) ───
|
||||
|
||||
STENO_KEYS = {
|
||||
'#': 0x00400000,
|
||||
'S-': 0x00000001, 'T-': 0x00000002, 'K-': 0x00000004,
|
||||
'P-': 0x00000008, 'W-': 0x00000010, 'H-': 0x00000020,
|
||||
'R-': 0x00000040, 'A-': 0x00000080, 'O-': 0x00000100,
|
||||
'*': 0x00000200, '-E': 0x00000400, '-U': 0x00000800,
|
||||
'-F': 0x00001000, '-R': 0x00002000, '-P': 0x00004000,
|
||||
'-B': 0x00008000, '-L': 0x00010000, '-G': 0x00020000,
|
||||
'-T': 0x00040000, '-S': 0x00080000, '-D': 0x00100000,
|
||||
'-Z': 0x00200000,
|
||||
}
|
||||
|
||||
IMPLICIT_HYPHEN = set('AOEU*')
|
||||
|
||||
|
||||
def parse_stroke(s):
|
||||
"""Parse a steno stroke string into a bitmask."""
|
||||
result = 0
|
||||
if '#' in s:
|
||||
result |= STENO_KEYS['#']
|
||||
s = s.replace('#', '')
|
||||
has_hyphen = '-' in s
|
||||
s_clean = s.replace('-', '')
|
||||
if not has_hyphen and not any(c in IMPLICIT_HYPHEN for c in s_clean):
|
||||
for c in s_clean:
|
||||
key = c + '-'
|
||||
if key in STENO_KEYS:
|
||||
result |= STENO_KEYS[key]
|
||||
return result
|
||||
past_vowels = False
|
||||
for c in s_clean:
|
||||
if c in 'AO':
|
||||
result |= STENO_KEYS[c + '-']
|
||||
past_vowels = True
|
||||
elif c in 'EU':
|
||||
result |= STENO_KEYS['-' + c]
|
||||
past_vowels = True
|
||||
elif c == '*':
|
||||
result |= STENO_KEYS['*']
|
||||
past_vowels = True
|
||||
elif not past_vowels and (c + '-') in STENO_KEYS:
|
||||
result |= STENO_KEYS[c + '-']
|
||||
elif past_vowels and ('-' + c) in STENO_KEYS:
|
||||
result |= STENO_KEYS['-' + c]
|
||||
elif has_hyphen:
|
||||
if s.index(c) < s.index('-'):
|
||||
result |= STENO_KEYS.get(c + '-', 0)
|
||||
else:
|
||||
result |= STENO_KEYS.get('-' + c, 0)
|
||||
else:
|
||||
if (c + '-') in STENO_KEYS:
|
||||
result |= STENO_KEYS[c + '-']
|
||||
return result
|
||||
|
||||
|
||||
def parse_stroke_string(stroke_str):
|
||||
"""Parse a stroke string (possibly multi-stroke with /) into tuple of bitmasks."""
|
||||
return tuple(parse_stroke(s) for s in stroke_str.split('/'))
|
||||
|
||||
|
||||
# ─── DAWG construction (Daciuk's incremental algorithm) ───
|
||||
|
||||
class DawgNode:
|
||||
"""Node in the DAWG."""
|
||||
__slots__ = ['id', 'edges', 'final', '_hash_cache']
|
||||
|
||||
_next_id = 0
|
||||
|
||||
def __init__(self):
|
||||
self.id = DawgNode._next_id
|
||||
DawgNode._next_id += 1
|
||||
self.edges = {} # stroke_val -> DawgNode
|
||||
self.final = False
|
||||
self._hash_cache = None
|
||||
|
||||
def signature(self):
|
||||
"""Hashable signature for minimization."""
|
||||
edge_sig = tuple(sorted(
|
||||
(k, child.id) for k, child in self.edges.items()
|
||||
))
|
||||
return (self.final, edge_sig)
|
||||
|
||||
def __hash__(self):
|
||||
if self._hash_cache is None:
|
||||
self._hash_cache = hash(self.signature())
|
||||
return self._hash_cache
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.signature() == other.signature()
|
||||
|
||||
def invalidate_cache(self):
|
||||
self._hash_cache = None
|
||||
|
||||
|
||||
def build_dawg(sorted_entries):
|
||||
"""Build minimized DAWG using Daciuk's incremental algorithm.
|
||||
|
||||
Entries MUST be sorted by stroke tuple (lexicographic).
|
||||
Returns (root, node_count, edge_count).
|
||||
"""
|
||||
DawgNode._next_id = 0
|
||||
root = DawgNode()
|
||||
unchecked = [] # list of (parent, stroke, child)
|
||||
minimized = {} # signature -> node
|
||||
prev_strokes = ()
|
||||
|
||||
def _minimize(down_to):
|
||||
"""Minimize unchecked nodes from top down to given depth."""
|
||||
for i in range(len(unchecked) - 1, down_to - 1, -1):
|
||||
parent, stroke, child = unchecked[i]
|
||||
child.invalidate_cache()
|
||||
sig = child.signature()
|
||||
if sig in minimized:
|
||||
parent.edges[stroke] = minimized[sig]
|
||||
else:
|
||||
minimized[sig] = child
|
||||
unchecked.pop()
|
||||
|
||||
for strokes, _translation in sorted_entries:
|
||||
# Find common prefix length with previous entry
|
||||
common = 0
|
||||
limit = min(len(strokes), len(prev_strokes))
|
||||
while common < limit and strokes[common] == prev_strokes[common]:
|
||||
common += 1
|
||||
|
||||
# Minimize nodes beyond common prefix
|
||||
_minimize(common)
|
||||
|
||||
# Get node at end of common prefix
|
||||
if unchecked:
|
||||
node = unchecked[-1][2]
|
||||
else:
|
||||
node = root
|
||||
|
||||
# Add new nodes for remaining strokes
|
||||
for stroke in strokes[common:]:
|
||||
new_node = DawgNode()
|
||||
node.edges[stroke] = new_node
|
||||
unchecked.append((node, stroke, new_node))
|
||||
node = new_node
|
||||
|
||||
node.final = True
|
||||
prev_strokes = strokes
|
||||
|
||||
# Minimize remaining
|
||||
_minimize(0)
|
||||
|
||||
# Count nodes and edges
|
||||
node_count = 0
|
||||
edge_count = 0
|
||||
visited = set()
|
||||
|
||||
def _count(n):
|
||||
nonlocal node_count, edge_count
|
||||
if n.id in visited:
|
||||
return
|
||||
visited.add(n.id)
|
||||
node_count += 1
|
||||
for _s, child in n.edges.items():
|
||||
edge_count += 1
|
||||
_count(child)
|
||||
|
||||
_count(root)
|
||||
return root, node_count, edge_count
|
||||
|
||||
|
||||
# ─── Skip-count computation ───
|
||||
|
||||
def compute_skip_counts(root):
|
||||
"""Compute skip-count (number of final nodes in subtree) for each node.
|
||||
|
||||
Returns dict: node_id -> skip_count
|
||||
"""
|
||||
cache = {}
|
||||
|
||||
def _count(node):
|
||||
if node.id in cache:
|
||||
return cache[node.id]
|
||||
c = 1 if node.final else 0
|
||||
for stroke in sorted(node.edges.keys()):
|
||||
child = node.edges[stroke]
|
||||
c += _count(child)
|
||||
cache[node.id] = c
|
||||
return c
|
||||
|
||||
_count(root)
|
||||
return cache
|
||||
|
||||
|
||||
def dawg_lookup_index(root, strokes, skip_cache):
|
||||
"""Look up a stroke sequence in the DAWG, returning its skip-count index.
|
||||
|
||||
Returns -1 if not found.
|
||||
"""
|
||||
node = root
|
||||
idx = 0
|
||||
|
||||
for stroke in strokes:
|
||||
if stroke not in node.edges:
|
||||
return -1
|
||||
# Count finals of all edges with stroke < target
|
||||
for s in sorted(node.edges.keys()):
|
||||
if s == stroke:
|
||||
child = node.edges[s]
|
||||
if child.final:
|
||||
idx += 1
|
||||
node = child
|
||||
break
|
||||
else:
|
||||
child = node.edges[s]
|
||||
idx += skip_cache[child.id]
|
||||
else:
|
||||
return -1
|
||||
|
||||
if not node.final:
|
||||
return -1
|
||||
return idx - 1
|
||||
|
||||
|
||||
def get_dawg_traversal_order(root):
|
||||
"""DFS traversal of DAWG, edges sorted by stroke value.
|
||||
|
||||
Returns list of translations in traversal order (one per final node encounter).
|
||||
This is the order entries appear when looking up via skip-count.
|
||||
"""
|
||||
order = []
|
||||
visited_paths = set()
|
||||
|
||||
def _dfs(node, path):
|
||||
path_key = tuple(path)
|
||||
if path_key in visited_paths:
|
||||
return
|
||||
visited_paths.add(path_key)
|
||||
|
||||
if node.final:
|
||||
order.append(path_key)
|
||||
|
||||
for stroke in sorted(node.edges.keys()):
|
||||
child = node.edges[stroke]
|
||||
_dfs(child, path + [stroke])
|
||||
|
||||
_dfs(root, [])
|
||||
return order
|
||||
|
||||
|
||||
# ─── Entry trimming ───
|
||||
|
||||
def trim_entries(entries, max_entries):
|
||||
"""Trim entries to max_entries, keeping single-stroke preferentially.
|
||||
|
||||
Priority:
|
||||
1. All single-stroke entries
|
||||
2. Multi-stroke entries sorted by translation length (shorter first)
|
||||
3. Drop longest/rarest multi-stroke first
|
||||
"""
|
||||
if len(entries) <= max_entries:
|
||||
return entries
|
||||
|
||||
single_stroke = []
|
||||
multi_stroke = []
|
||||
|
||||
for strokes_str, translation in entries:
|
||||
if '/' not in strokes_str:
|
||||
single_stroke.append((strokes_str, translation))
|
||||
else:
|
||||
multi_stroke.append((strokes_str, translation))
|
||||
|
||||
# Sort multi-stroke by translation length (shorter = more useful)
|
||||
multi_stroke.sort(key=lambda x: (len(x[1]), len(x[0].split('/'))))
|
||||
|
||||
remaining = max_entries - len(single_stroke)
|
||||
if remaining < 0:
|
||||
# Even single-stroke entries exceed limit; trim by translation length
|
||||
single_stroke.sort(key=lambda x: len(x[1]))
|
||||
return single_stroke[:max_entries]
|
||||
|
||||
return single_stroke + multi_stroke[:remaining]
|
||||
|
||||
|
||||
# ─── String table construction ───
|
||||
|
||||
def build_string_table(translations):
|
||||
"""Build block-compressed string table from translations.
|
||||
|
||||
Returns (table_bytes, offsets) where offsets[i] is the byte offset
|
||||
of translation i in the uncompressed table.
|
||||
"""
|
||||
# Build raw table: null-separated strings
|
||||
# We need to track offset of each unique string
|
||||
unique_translations = sorted(set(translations))
|
||||
trans_to_unique_idx = {t: i for i, t in enumerate(unique_translations)}
|
||||
|
||||
# Build raw bytes and offset map
|
||||
raw_parts = []
|
||||
unique_offsets = []
|
||||
offset = 0
|
||||
for t in unique_translations:
|
||||
unique_offsets.append(offset)
|
||||
encoded = t.encode('utf-8')
|
||||
raw_parts.append(encoded)
|
||||
offset += len(encoded) + 1 # +1 for null separator
|
||||
|
||||
raw = b'\x00'.join(raw_parts)
|
||||
if raw_parts:
|
||||
raw += b'\x00' # trailing null
|
||||
|
||||
# Block compress
|
||||
block_size = 4096
|
||||
compressed_blocks = []
|
||||
block_offsets_raw = []
|
||||
current_offset = 0
|
||||
|
||||
for i in range(0, len(raw), block_size):
|
||||
block = raw[i:i + block_size]
|
||||
compressed = zlib.compress(block, 9)
|
||||
block_offsets_raw.append(current_offset)
|
||||
compressed_blocks.append(compressed)
|
||||
current_offset += len(compressed)
|
||||
|
||||
# Serialize: block_count(u16) + block_offsets(u32 each) + compressed blocks
|
||||
n_blocks = len(compressed_blocks)
|
||||
table_header = struct.pack('<H', n_blocks)
|
||||
table_index = b''.join(struct.pack('<I', off) for off in block_offsets_raw)
|
||||
table_data = b''.join(compressed_blocks)
|
||||
table_bytes = table_header + table_index + table_data
|
||||
|
||||
# Map each translation to its offset in raw table
|
||||
entry_offsets = []
|
||||
for t in translations:
|
||||
uid = trans_to_unique_idx[t]
|
||||
entry_offsets.append(unique_offsets[uid])
|
||||
|
||||
return table_bytes, entry_offsets, len(raw)
|
||||
|
||||
|
||||
def decompress_string_table(table_bytes):
|
||||
"""Decompress a block-compressed string table back to raw bytes."""
|
||||
pos = 0
|
||||
n_blocks = struct.unpack_from('<H', table_bytes, pos)[0]
|
||||
pos += 2
|
||||
|
||||
block_offsets = []
|
||||
for _ in range(n_blocks):
|
||||
off = struct.unpack_from('<I', table_bytes, pos)[0]
|
||||
pos += 4
|
||||
block_offsets.append(off)
|
||||
|
||||
data_start = pos
|
||||
raw_parts = []
|
||||
for i in range(n_blocks):
|
||||
block_start = data_start + block_offsets[i]
|
||||
if i + 1 < n_blocks:
|
||||
block_end = data_start + block_offsets[i + 1]
|
||||
else:
|
||||
block_end = len(table_bytes)
|
||||
compressed = table_bytes[block_start:block_end]
|
||||
raw_parts.append(zlib.decompress(compressed))
|
||||
|
||||
return b''.join(raw_parts)
|
||||
|
||||
|
||||
def lookup_string(raw_table, offset):
|
||||
"""Look up a null-terminated string at given offset in raw table."""
|
||||
end = raw_table.index(b'\x00', offset)
|
||||
return raw_table[offset:end].decode('utf-8')
|
||||
|
||||
|
||||
# ─── Binary serialization ───
|
||||
|
||||
MAGIC = b'STNO'
|
||||
VERSION = 1
|
||||
FLAG_SPLIT_STORAGE = 0x0001
|
||||
|
||||
HEADER_SIZE = 32
|
||||
|
||||
|
||||
def serialize_header(flags, entry_count, node_count, edge_count,
|
||||
string_table_offset, string_table_size,
|
||||
value_array_offset):
|
||||
"""Serialize the 32-byte binary header."""
|
||||
return struct.pack('<4sHHIIIIIIxxxx',
|
||||
MAGIC,
|
||||
VERSION,
|
||||
flags,
|
||||
entry_count,
|
||||
node_count,
|
||||
edge_count,
|
||||
string_table_offset,
|
||||
string_table_size,
|
||||
value_array_offset)
|
||||
|
||||
|
||||
def parse_header(data):
|
||||
"""Parse 32-byte binary header. Returns dict."""
|
||||
if len(data) < HEADER_SIZE:
|
||||
raise ValueError("Data too short for header")
|
||||
# Unpack with padding bytes
|
||||
magic, version, flags, entry_count, node_count, edge_count, \
|
||||
str_table_off, str_table_size, val_array_off = \
|
||||
struct.unpack_from('<4sHHIIIIII', data, 0)
|
||||
# 4 bytes reserved at end (32 - 28 = 4)
|
||||
if magic != MAGIC:
|
||||
raise ValueError(f"Bad magic: {magic!r}")
|
||||
return {
|
||||
'magic': magic,
|
||||
'version': version,
|
||||
'flags': flags,
|
||||
'entry_count': entry_count,
|
||||
'node_count': node_count,
|
||||
'edge_count': edge_count,
|
||||
'string_table_offset': str_table_off,
|
||||
'string_table_size': str_table_size,
|
||||
'value_array_offset': val_array_off,
|
||||
}
|
||||
|
||||
|
||||
def serialize_edges(root, node_count, edge_count):
|
||||
"""Serialize DAWG edges as bit-packed array.
|
||||
|
||||
Each edge: stroke_key(16) + target_node(16) + skip_count(17) + is_last(1) = 50 bits
|
||||
|
||||
Nodes are assigned sequential IDs via DFS traversal (sorted edges).
|
||||
Returns (edge_bytes, node_id_map, skip_counts_by_node).
|
||||
"""
|
||||
# Assign sequential node IDs via DFS
|
||||
node_id_map = {}
|
||||
dfs_order = []
|
||||
|
||||
def _assign_ids(node):
|
||||
if node.id in node_id_map:
|
||||
return
|
||||
new_id = len(node_id_map)
|
||||
node_id_map[node.id] = new_id
|
||||
dfs_order.append(node)
|
||||
for stroke in sorted(node.edges.keys()):
|
||||
child = node.edges[stroke]
|
||||
_assign_ids(child)
|
||||
|
||||
_assign_ids(root)
|
||||
|
||||
# Compute skip counts
|
||||
skip_cache = compute_skip_counts(root)
|
||||
|
||||
# Build edge list: for each node in DFS order, emit edges sorted by stroke
|
||||
edges = []
|
||||
for node in dfs_order:
|
||||
sorted_strokes = sorted(node.edges.keys())
|
||||
for i, stroke in enumerate(sorted_strokes):
|
||||
child = node.edges[stroke]
|
||||
is_last = (i == len(sorted_strokes) - 1)
|
||||
target_id = node_id_map[child.id]
|
||||
skip = skip_cache[child.id]
|
||||
edges.append((stroke, target_id, skip, is_last))
|
||||
|
||||
# Bit-pack edges: each 50 bits
|
||||
# stroke_key: 16 bits, target_node: 16 bits, skip_count: 17 bits, is_last: 1 bit
|
||||
total_bits = len(edges) * 50
|
||||
total_bytes = (total_bits + 7) // 8
|
||||
buf = bytearray(total_bytes)
|
||||
|
||||
bit_pos = 0
|
||||
for stroke_key, target_node, skip_count, is_last in edges:
|
||||
# Clamp values to field widths
|
||||
stroke_key &= 0xFFFF
|
||||
target_node &= 0xFFFF
|
||||
skip_count = min(skip_count, 0x1FFFF) # 17 bits max
|
||||
is_last_bit = 1 if is_last else 0
|
||||
|
||||
# Pack 50 bits: stroke(16) | target(16) | skip(17) | last(1)
|
||||
val = (stroke_key << 34) | (target_node << 18) | (skip_count << 1) | is_last_bit
|
||||
|
||||
# Write 50 bits into buffer at bit_pos
|
||||
for i in range(50):
|
||||
bit = (val >> (49 - i)) & 1
|
||||
byte_idx = (bit_pos + i) // 8
|
||||
bit_idx = 7 - ((bit_pos + i) % 8)
|
||||
if bit:
|
||||
buf[byte_idx] |= (1 << bit_idx)
|
||||
|
||||
bit_pos += 50
|
||||
|
||||
return bytes(buf), node_id_map, skip_cache
|
||||
|
||||
|
||||
def deserialize_edges(edge_bytes, edge_count):
|
||||
"""Deserialize bit-packed edge array.
|
||||
|
||||
Returns list of (stroke_key, target_node, skip_count, is_last).
|
||||
"""
|
||||
edges = []
|
||||
bit_pos = 0
|
||||
|
||||
for _ in range(edge_count):
|
||||
val = 0
|
||||
for i in range(50):
|
||||
byte_idx = (bit_pos + i) // 8
|
||||
bit_idx = 7 - ((bit_pos + i) % 8)
|
||||
bit = (edge_bytes[byte_idx] >> bit_idx) & 1
|
||||
val = (val << 1) | bit
|
||||
bit_pos += 50
|
||||
|
||||
stroke_key = (val >> 34) & 0xFFFF
|
||||
target_node = (val >> 18) & 0xFFFF
|
||||
skip_count = (val >> 1) & 0x1FFFF
|
||||
is_last = val & 1
|
||||
edges.append((stroke_key, target_node, skip_count, bool(is_last)))
|
||||
|
||||
return edges
|
||||
|
||||
|
||||
def serialize_value_array(entry_offsets, raw_table_size):
|
||||
"""Serialize value array (string table offsets for each entry).
|
||||
|
||||
Uses uint16 if raw_table_size <= 65535, else uint32.
|
||||
"""
|
||||
use_u32 = raw_table_size > 65535
|
||||
fmt = '<I' if use_u32 else '<H'
|
||||
parts = [struct.pack(fmt, off) for off in entry_offsets]
|
||||
return b''.join(parts), use_u32
|
||||
|
||||
|
||||
def deserialize_value_array(data, entry_count, use_u32=False):
|
||||
"""Deserialize value array."""
|
||||
fmt = '<I' if use_u32 else '<H'
|
||||
size = 4 if use_u32 else 2
|
||||
offsets = []
|
||||
for i in range(entry_count):
|
||||
off = struct.unpack_from(fmt, data, i * size)[0]
|
||||
offsets.append(off)
|
||||
return offsets
|
||||
|
||||
|
||||
# ─── Full compilation pipeline ───
|
||||
|
||||
def compile_dictionary(json_path, max_entries=120000, split_storage=False):
|
||||
"""Compile a JSON steno dictionary into binary DAWG format.
|
||||
|
||||
Returns (binary_data, stats_dict).
|
||||
"""
|
||||
# 1. Load and parse
|
||||
with open(json_path) as f:
|
||||
raw_dict = json.load(f)
|
||||
|
||||
raw_entries = list(raw_dict.items())
|
||||
|
||||
# 2. Trim if needed
|
||||
if len(raw_entries) > max_entries:
|
||||
raw_entries = trim_entries(raw_entries, max_entries)
|
||||
|
||||
# 3. Parse strokes and sort
|
||||
parsed = []
|
||||
for stroke_str, translation in raw_entries:
|
||||
strokes = parse_stroke_string(stroke_str)
|
||||
parsed.append((strokes, translation))
|
||||
|
||||
parsed.sort(key=lambda x: x[0])
|
||||
|
||||
# 4. Build DAWG
|
||||
root, node_count, edge_count = build_dawg(parsed)
|
||||
|
||||
# 5. Get traversal order for value array
|
||||
traversal_paths = get_dawg_traversal_order(root)
|
||||
|
||||
# Build path->translation map
|
||||
path_to_trans = {}
|
||||
for strokes, translation in parsed:
|
||||
path_to_trans[strokes] = translation
|
||||
|
||||
translations_ordered = []
|
||||
for path in traversal_paths:
|
||||
if path in path_to_trans:
|
||||
translations_ordered.append(path_to_trans[path])
|
||||
else:
|
||||
translations_ordered.append("")
|
||||
|
||||
# 6. Build string table
|
||||
string_table_bytes, entry_offsets, raw_table_size = \
|
||||
build_string_table(translations_ordered)
|
||||
|
||||
# 7. Serialize edges
|
||||
edge_bytes, node_id_map, skip_cache = \
|
||||
serialize_edges(root, node_count, edge_count)
|
||||
|
||||
# 8. Serialize value array
|
||||
value_array_bytes, use_u32 = \
|
||||
serialize_value_array(entry_offsets, raw_table_size)
|
||||
|
||||
# 9. Compute offsets
|
||||
edge_array_offset = HEADER_SIZE
|
||||
value_array_offset = edge_array_offset + len(edge_bytes)
|
||||
string_table_offset = value_array_offset + len(value_array_bytes)
|
||||
|
||||
# 10. Build header
|
||||
flags = 0
|
||||
if split_storage:
|
||||
flags |= FLAG_SPLIT_STORAGE
|
||||
if use_u32:
|
||||
flags |= 0x0002 # bit 1 = u32 value offsets
|
||||
|
||||
header = serialize_header(
|
||||
flags=flags,
|
||||
entry_count=len(translations_ordered),
|
||||
node_count=node_count,
|
||||
edge_count=edge_count,
|
||||
string_table_offset=string_table_offset,
|
||||
string_table_size=len(string_table_bytes),
|
||||
value_array_offset=value_array_offset,
|
||||
)
|
||||
|
||||
# 11. Assemble
|
||||
binary = header + edge_bytes + value_array_bytes + string_table_bytes
|
||||
|
||||
stats = {
|
||||
'entry_count': len(translations_ordered),
|
||||
'node_count': node_count,
|
||||
'edge_count': edge_count,
|
||||
'bits_per_edge': 50,
|
||||
'edge_array_size': len(edge_bytes),
|
||||
'value_array_size': len(value_array_bytes),
|
||||
'string_table_size': len(string_table_bytes),
|
||||
'raw_string_table_size': raw_table_size,
|
||||
'total_size': len(binary),
|
||||
'header_size': HEADER_SIZE,
|
||||
'use_u32_offsets': use_u32,
|
||||
'split_storage': split_storage,
|
||||
}
|
||||
|
||||
return binary, stats, root, skip_cache, translations_ordered, parsed
|
||||
|
||||
|
||||
def verify_compilation(binary_data, parsed_entries, root, skip_cache):
|
||||
"""Verify compiled binary by deserializing and looking up every entry.
|
||||
|
||||
Returns (correct, wrong, missing).
|
||||
"""
|
||||
header = parse_header(binary_data)
|
||||
entry_count = header['entry_count']
|
||||
edge_count = header['edge_count']
|
||||
use_u32 = bool(header['flags'] & 0x0002)
|
||||
|
||||
# Extract sections
|
||||
edge_start = HEADER_SIZE
|
||||
edge_end = header['value_array_offset']
|
||||
edge_bytes = binary_data[edge_start:edge_end]
|
||||
|
||||
val_start = header['value_array_offset']
|
||||
val_end = header['string_table_offset']
|
||||
val_bytes = binary_data[val_start:val_end]
|
||||
|
||||
str_start = header['string_table_offset']
|
||||
str_bytes = binary_data[str_start:]
|
||||
|
||||
# Deserialize
|
||||
edges = deserialize_edges(edge_bytes, edge_count)
|
||||
value_offsets = deserialize_value_array(val_bytes, entry_count, use_u32)
|
||||
raw_table = decompress_string_table(str_bytes)
|
||||
|
||||
# Build adjacency from deserialized edges for lookup
|
||||
# Reconstruct graph: node_id -> list of (stroke, target, skip)
|
||||
adj = {}
|
||||
node_finals = set()
|
||||
edge_idx = 0
|
||||
|
||||
# We need to figure out which edges belong to which node.
|
||||
# Edges are stored in DFS node order; is_last marks end of a node's edge list.
|
||||
current_node = 0
|
||||
node_edges = {}
|
||||
|
||||
i = 0
|
||||
while i < len(edges):
|
||||
stroke, target, skip, is_last = edges[i]
|
||||
if current_node not in node_edges:
|
||||
node_edges[current_node] = []
|
||||
node_edges[current_node].append((stroke, target, skip))
|
||||
if is_last:
|
||||
current_node += 1
|
||||
# Skip nodes that have no edges (they won't appear in edge list)
|
||||
# We detect these by checking if next edge's parent should be higher
|
||||
i += 1
|
||||
|
||||
# Now look up each entry via the reconstructed DAWG
|
||||
correct = 0
|
||||
wrong = 0
|
||||
missing = 0
|
||||
|
||||
for strokes, expected in parsed_entries:
|
||||
idx = dawg_lookup_index(root, strokes, skip_cache)
|
||||
if idx < 0 or idx >= len(value_offsets):
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
offset = value_offsets[idx]
|
||||
# Find null terminator
|
||||
try:
|
||||
translation = lookup_string(raw_table, offset)
|
||||
except (ValueError, IndexError):
|
||||
missing += 1
|
||||
continue
|
||||
|
||||
if translation == expected:
|
||||
correct += 1
|
||||
else:
|
||||
wrong += 1
|
||||
if wrong <= 5:
|
||||
print(f" WRONG: strokes={strokes}, expected='{expected}', got='{translation}'")
|
||||
|
||||
return correct, wrong, missing
|
||||
|
||||
|
||||
def print_stats(stats, target_kb=533):
|
||||
"""Print compilation statistics."""
|
||||
target_bytes = target_kb * 1024
|
||||
total = stats['total_size']
|
||||
pct = (total / target_bytes) * 100 if target_bytes else 0
|
||||
|
||||
print(f"Dictionary Compilation Stats:")
|
||||
print(f" Entries: {stats['entry_count']:>10,}")
|
||||
print(f" DAWG nodes: {stats['node_count']:>10,}")
|
||||
print(f" DAWG edges: {stats['edge_count']:>10,}")
|
||||
print(f" Bits/edge: {stats['bits_per_edge']:>10}")
|
||||
print(f" ---")
|
||||
print(f" Header: {stats['header_size']:>10,} bytes")
|
||||
print(f" Edge array: {stats['edge_array_size']:>10,} bytes ({stats['edge_array_size']/1024:.1f} KB)")
|
||||
print(f" Value array: {stats['value_array_size']:>10,} bytes ({stats['value_array_size']/1024:.1f} KB)")
|
||||
print(f" String table: {stats['string_table_size']:>10,} bytes ({stats['string_table_size']/1024:.1f} KB)")
|
||||
print(f" ---")
|
||||
print(f" TOTAL: {total:>10,} bytes ({total/1024:.1f} KB)")
|
||||
print(f" Budget: {target_bytes:>10,} bytes ({target_kb} KB)")
|
||||
print(f" Usage: {pct:>9.1f}%")
|
||||
if total <= target_bytes:
|
||||
print(f" Status: FITS ({(target_bytes - total)/1024:.1f} KB remaining)")
|
||||
else:
|
||||
print(f" Status: OVER BUDGET by {(total - target_bytes)/1024:.1f} KB")
|
||||
print(f" ---")
|
||||
print(f" Split storage: {'yes' if stats['split_storage'] else 'no'}")
|
||||
print(f" U32 offsets: {'yes' if stats['use_u32_offsets'] else 'no'}")
|
||||
|
||||
|
||||
# ─── CLI ───
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Compile steno dictionary to binary DAWG format')
|
||||
parser.add_argument('input', nargs='?', default='/tmp/plover-main.json',
|
||||
help='Path to JSON dictionary (default: /tmp/plover-main.json)')
|
||||
parser.add_argument('--output', default='steno_dict.bin',
|
||||
help='Output binary path (default: steno_dict.bin)')
|
||||
parser.add_argument('--max-entries', type=int, default=120000,
|
||||
help='Max entries to include (default: 120000)')
|
||||
parser.add_argument('--target-size', type=int, default=462,
|
||||
help='Target size in KB (default: 462)')
|
||||
parser.add_argument('--split-storage', action='store_true',
|
||||
help='Generate split-storage metadata header')
|
||||
parser.add_argument('--stats', action='store_true',
|
||||
help='Print detailed stats')
|
||||
parser.add_argument('--verify', action='store_true',
|
||||
help='Verify all entries after compilation')
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.input):
|
||||
print(f"ERROR: Dictionary not found: {args.input}")
|
||||
print("Download Plover dict:")
|
||||
print(" curl -sL 'https://raw.githubusercontent.com/openstenoproject/plover/main/plover/assets/main.json' -o /tmp/plover-main.json")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Compiling {args.input}...")
|
||||
binary, stats, root, skip_cache, translations, parsed = \
|
||||
compile_dictionary(args.input, args.max_entries, args.split_storage)
|
||||
|
||||
# Write output
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(binary)
|
||||
print(f"Written {len(binary):,} bytes to {args.output}")
|
||||
|
||||
if args.stats:
|
||||
print()
|
||||
print_stats(stats, args.target_size)
|
||||
|
||||
if args.verify:
|
||||
print()
|
||||
print("Verifying...")
|
||||
correct, wrong, missing = verify_compilation(binary, parsed, root, skip_cache)
|
||||
total = correct + wrong + missing
|
||||
print(f" Correct: {correct}/{total}")
|
||||
print(f" Wrong: {wrong}/{total}")
|
||||
print(f" Missing: {missing}/{total}")
|
||||
if wrong > 0 or missing > 0:
|
||||
print(" WARNING: Verification found errors!")
|
||||
sys.exit(2)
|
||||
else:
|
||||
print(" All entries verified successfully.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
106
tools/fetch_dict.py
Normal file
106
tools/fetch_dict.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Download steno dictionary with caching.
|
||||
|
||||
Checks local file hash against known upstream hash.
|
||||
If file exists and hash matches → skip download.
|
||||
If file missing or hash mismatch → download fresh.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
DICTS = {
|
||||
"plover": {
|
||||
"url": "https://raw.githubusercontent.com/openstenoproject/plover/main/plover/assets/main.json",
|
||||
"filename": "plover-main.json",
|
||||
},
|
||||
"lapwing": {
|
||||
"url": "https://raw.githubusercontent.com/aerickt/steno-dictionaries/main/lapwing-base.json",
|
||||
"filename": "lapwing.json",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sha256_file(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def download(url, dest):
|
||||
import subprocess
|
||||
print(f"Downloading {url}...")
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
except urllib.error.URLError:
|
||||
# Fallback to curl which uses system cert store
|
||||
subprocess.check_call(["curl", "-sL", "-o", dest, url])
|
||||
size = os.path.getsize(dest)
|
||||
print(f"Downloaded {size:,} bytes to {dest}")
|
||||
|
||||
|
||||
def fetch(dict_name, dest_dir):
|
||||
if dict_name not in DICTS:
|
||||
print(f"Unknown dict: {dict_name}. Available: {', '.join(DICTS.keys())}")
|
||||
return 1
|
||||
|
||||
info = DICTS[dict_name]
|
||||
dest = os.path.join(dest_dir, info["filename"])
|
||||
hash_file = dest + ".sha256"
|
||||
|
||||
if os.path.exists(dest):
|
||||
local_hash = sha256_file(dest)
|
||||
|
||||
if os.path.exists(hash_file):
|
||||
with open(hash_file) as f:
|
||||
cached_hash = f.read().strip()
|
||||
if local_hash == cached_hash:
|
||||
print(f"{dest} up to date (sha256={local_hash[:12]}...)")
|
||||
return 0
|
||||
|
||||
try:
|
||||
json.load(open(dest))
|
||||
print(f"{dest} exists, valid JSON (sha256={local_hash[:12]}...)")
|
||||
with open(hash_file, "w") as f:
|
||||
f.write(local_hash)
|
||||
return 0
|
||||
except (json.JSONDecodeError, IOError):
|
||||
print(f"{dest} corrupted, re-downloading")
|
||||
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
download(info["url"], dest)
|
||||
|
||||
new_hash = sha256_file(dest)
|
||||
with open(hash_file, "w") as f:
|
||||
f.write(new_hash)
|
||||
|
||||
try:
|
||||
with open(dest) as f:
|
||||
d = json.load(f)
|
||||
print(f"Verified: {len(d)} entries")
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"WARNING: downloaded file invalid: {e}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <plover|lapwing> [dest_dir]")
|
||||
print(f" dest_dir defaults to ./dicts/")
|
||||
sys.exit(1)
|
||||
|
||||
dict_name = sys.argv[1]
|
||||
dest_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dicts")
|
||||
|
||||
sys.exit(fetch(dict_name, dest_dir))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
tools/requirements.txt
Normal file
5
tools/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# zmk-steno-engine dictionary compiler
|
||||
# stdlib only — no external dependencies required
|
||||
#
|
||||
# Python >= 3.8
|
||||
# Uses: json, struct, zlib, argparse, sys, os, collections, math
|
||||
460
tools/test_compiler.py
Normal file
460
tools/test_compiler.py
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Tests for dict_compiler.py — DAWG dictionary compiler."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure tools/ is importable
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from dict_compiler import (
|
||||
STENO_KEYS,
|
||||
FLAG_SPLIT_STORAGE,
|
||||
HEADER_SIZE,
|
||||
MAGIC,
|
||||
VERSION,
|
||||
build_dawg,
|
||||
build_string_table,
|
||||
compile_dictionary,
|
||||
compute_skip_counts,
|
||||
dawg_lookup_index,
|
||||
decompress_string_table,
|
||||
deserialize_edges,
|
||||
deserialize_value_array,
|
||||
get_dawg_traversal_order,
|
||||
lookup_string,
|
||||
parse_header,
|
||||
parse_stroke,
|
||||
parse_stroke_string,
|
||||
serialize_edges,
|
||||
serialize_header,
|
||||
serialize_value_array,
|
||||
trim_entries,
|
||||
verify_compilation,
|
||||
)
|
||||
|
||||
|
||||
# ─── Small test dictionaries ───
|
||||
|
||||
SMALL_DICT = {
|
||||
"S": "is",
|
||||
"T": "it",
|
||||
"K": "can",
|
||||
"W": "with",
|
||||
"H": "had",
|
||||
"R": "are",
|
||||
"TPHO": "no",
|
||||
"STPH": "then",
|
||||
"KAT": "cat",
|
||||
"TKOG": "dog",
|
||||
}
|
||||
|
||||
MULTI_STROKE_DICT = {
|
||||
"S": "is",
|
||||
"T": "it",
|
||||
"KPA/HROL": "{}{-|}",
|
||||
"TPHO/WUPB": "no one",
|
||||
"K": "can",
|
||||
}
|
||||
|
||||
MEDIUM_DICT = {
|
||||
"S": "is",
|
||||
"T": "it",
|
||||
"K": "can",
|
||||
"W": "with",
|
||||
"H": "had",
|
||||
"R": "are",
|
||||
"A": "a",
|
||||
"O": "oh",
|
||||
"E": "he",
|
||||
"U": "you",
|
||||
"TPHO": "no",
|
||||
"STPH": "then",
|
||||
"KAT": "cat",
|
||||
"TKOG": "dog",
|
||||
"HOUS": "house",
|
||||
"TPHAEUPL": "name",
|
||||
"HROS": "also",
|
||||
"TKPWRAET": "great",
|
||||
"SKEL": "school",
|
||||
"PLAS": "place",
|
||||
}
|
||||
|
||||
|
||||
def _make_temp_dict(d):
|
||||
"""Write dict to temp JSON file, return path."""
|
||||
fd, path = tempfile.mkstemp(suffix='.json')
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(d, f)
|
||||
return path
|
||||
|
||||
|
||||
def _parse_and_sort(d):
|
||||
"""Parse dict entries and sort by stroke tuple."""
|
||||
entries = []
|
||||
for stroke_str, translation in d.items():
|
||||
strokes = parse_stroke_string(stroke_str)
|
||||
entries.append((strokes, translation))
|
||||
entries.sort(key=lambda x: x[0])
|
||||
return entries
|
||||
|
||||
|
||||
# ─── Tests ───
|
||||
|
||||
class TestParseStroke:
|
||||
"""Test stroke parsing."""
|
||||
|
||||
def test_left_side_stph(self):
|
||||
"""STPH → S + T + P + H left side bits."""
|
||||
val = parse_stroke("STPH")
|
||||
expected = STENO_KEYS['S-'] | STENO_KEYS['T-'] | STENO_KEYS['P-'] | STENO_KEYS['H-']
|
||||
assert val == expected
|
||||
|
||||
def test_right_side_eurb(self):
|
||||
"""EURB → E + U + R + B right side bits."""
|
||||
val = parse_stroke("EURB")
|
||||
expected = STENO_KEYS['-E'] | STENO_KEYS['-U'] | STENO_KEYS['-R'] | STENO_KEYS['-B']
|
||||
assert val == expected
|
||||
|
||||
def test_single_s(self):
|
||||
"""S → just S bit."""
|
||||
val = parse_stroke("S")
|
||||
assert val == STENO_KEYS['S-']
|
||||
|
||||
def test_number_bar(self):
|
||||
"""#STPH → number + S + T + P + H."""
|
||||
val = parse_stroke("#STPH")
|
||||
expected = (STENO_KEYS['#'] | STENO_KEYS['S-'] | STENO_KEYS['T-'] |
|
||||
STENO_KEYS['P-'] | STENO_KEYS['H-'])
|
||||
assert val == expected
|
||||
|
||||
def test_vowels(self):
|
||||
"""AO → A + O vowel bits."""
|
||||
val = parse_stroke("AO")
|
||||
expected = STENO_KEYS['A-'] | STENO_KEYS['O-']
|
||||
assert val == expected
|
||||
|
||||
def test_full_stroke(self):
|
||||
"""STKPWHR → all left consonants."""
|
||||
val = parse_stroke("STKPWHR")
|
||||
expected = (STENO_KEYS['S-'] | STENO_KEYS['T-'] | STENO_KEYS['K-'] |
|
||||
STENO_KEYS['P-'] | STENO_KEYS['W-'] | STENO_KEYS['H-'] |
|
||||
STENO_KEYS['R-'])
|
||||
assert val == expected
|
||||
|
||||
def test_star(self):
|
||||
"""*E → star + E."""
|
||||
val = parse_stroke("*E")
|
||||
expected = STENO_KEYS['*'] | STENO_KEYS['-E']
|
||||
assert val == expected
|
||||
|
||||
def test_multi_stroke_parse(self):
|
||||
"""KPA/HROL parses to two stroke bitmasks."""
|
||||
strokes = parse_stroke_string("KPA/HROL")
|
||||
assert len(strokes) == 2
|
||||
# First stroke: K + P + A
|
||||
assert strokes[0] == (STENO_KEYS['K-'] | STENO_KEYS['P-'] | STENO_KEYS['A-'])
|
||||
|
||||
|
||||
class TestBuildDawgSmall:
|
||||
"""Test DAWG construction with small dictionary."""
|
||||
|
||||
def test_node_compression(self):
|
||||
"""DAWG should have fewer nodes than a plain trie (compression happening)."""
|
||||
entries = _parse_and_sort(SMALL_DICT)
|
||||
root, node_count, edge_count = build_dawg(entries)
|
||||
# With 10 entries, DAWG should have fewer nodes than
|
||||
# total path length (which would be ~15+ for a trie)
|
||||
assert node_count < 15
|
||||
assert node_count > 0
|
||||
assert edge_count > 0
|
||||
|
||||
def test_all_entries_reachable(self):
|
||||
"""All entries should be reachable via traversal."""
|
||||
entries = _parse_and_sort(SMALL_DICT)
|
||||
root, node_count, edge_count = build_dawg(entries)
|
||||
|
||||
# Check each entry can be traversed
|
||||
for strokes, _trans in entries:
|
||||
node = root
|
||||
for stroke in strokes:
|
||||
assert stroke in node.edges, f"Missing edge for stroke in {strokes}"
|
||||
node = node.edges[stroke]
|
||||
assert node.final, f"Node not final for {strokes}"
|
||||
|
||||
|
||||
class TestDawgLookup:
|
||||
"""Test DAWG lookup via skip-count traversal."""
|
||||
|
||||
def test_all_lookups_correct(self):
|
||||
"""All entries should have unique sequential skip-count indices."""
|
||||
entries = _parse_and_sort(SMALL_DICT)
|
||||
root, _, _ = build_dawg(entries)
|
||||
skip_cache = compute_skip_counts(root)
|
||||
|
||||
indices = []
|
||||
for strokes, _ in entries:
|
||||
idx = dawg_lookup_index(root, strokes, skip_cache)
|
||||
assert idx >= 0, f"Lookup failed for {strokes}"
|
||||
indices.append(idx)
|
||||
|
||||
# All indices should be unique
|
||||
assert len(set(indices)) == len(indices), "Duplicate indices found"
|
||||
|
||||
# Indices should be 0..n-1
|
||||
assert sorted(indices) == list(range(len(entries)))
|
||||
|
||||
def test_missing_entry_returns_neg(self):
|
||||
"""Looking up a non-existent stroke should return -1."""
|
||||
entries = _parse_and_sort(SMALL_DICT)
|
||||
root, _, _ = build_dawg(entries)
|
||||
skip_cache = compute_skip_counts(root)
|
||||
|
||||
# A stroke not in the dict
|
||||
fake_strokes = (0xDEAD,)
|
||||
idx = dawg_lookup_index(root, fake_strokes, skip_cache)
|
||||
assert idx == -1
|
||||
|
||||
def test_traversal_order_matches(self):
|
||||
"""Traversal order should match sorted entries."""
|
||||
entries = _parse_and_sort(SMALL_DICT)
|
||||
root, _, _ = build_dawg(entries)
|
||||
|
||||
traversal = get_dawg_traversal_order(root)
|
||||
entry_paths = [strokes for strokes, _ in entries]
|
||||
|
||||
assert traversal == entry_paths
|
||||
|
||||
|
||||
class TestBinaryRoundTrip:
|
||||
"""Test compile → binary → deserialize → verify."""
|
||||
|
||||
def test_small_dict_round_trip(self):
|
||||
"""Compile small dict, deserialize, verify all lookups."""
|
||||
path = _make_temp_dict(SMALL_DICT)
|
||||
try:
|
||||
binary, stats, root, skip_cache, translations, parsed = \
|
||||
compile_dictionary(path, max_entries=100)
|
||||
|
||||
assert stats['entry_count'] == len(SMALL_DICT)
|
||||
assert stats['total_size'] == len(binary)
|
||||
assert stats['total_size'] > HEADER_SIZE
|
||||
|
||||
# Verify header
|
||||
header = parse_header(binary)
|
||||
assert header['magic'] == MAGIC
|
||||
assert header['version'] == VERSION
|
||||
assert header['entry_count'] == len(SMALL_DICT)
|
||||
|
||||
# Verify round-trip
|
||||
correct, wrong, missing = verify_compilation(binary, parsed, root, skip_cache)
|
||||
assert correct == len(SMALL_DICT)
|
||||
assert wrong == 0
|
||||
assert missing == 0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_medium_dict_round_trip(self):
|
||||
"""Medium dict round-trip."""
|
||||
path = _make_temp_dict(MEDIUM_DICT)
|
||||
try:
|
||||
binary, stats, root, skip_cache, translations, parsed = \
|
||||
compile_dictionary(path, max_entries=100)
|
||||
|
||||
correct, wrong, missing = verify_compilation(binary, parsed, root, skip_cache)
|
||||
assert correct == len(MEDIUM_DICT)
|
||||
assert wrong == 0
|
||||
assert missing == 0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_edge_serialization_round_trip(self):
|
||||
"""Edge bit-packing round-trip."""
|
||||
entries = _parse_and_sort(SMALL_DICT)
|
||||
root, node_count, edge_count = build_dawg(entries)
|
||||
|
||||
edge_bytes, node_id_map, skip_cache = serialize_edges(root, node_count, edge_count)
|
||||
edges = deserialize_edges(edge_bytes, edge_count)
|
||||
|
||||
assert len(edges) == edge_count
|
||||
# Each edge should have valid fields
|
||||
for stroke, target, skip, is_last in edges:
|
||||
assert 0 <= stroke <= 0xFFFF
|
||||
assert 0 <= target <= 0xFFFF
|
||||
assert 0 <= skip <= 0x1FFFF
|
||||
|
||||
def test_string_table_round_trip(self):
|
||||
"""String table compress/decompress round-trip."""
|
||||
translations = list(SMALL_DICT.values())
|
||||
table_bytes, offsets, raw_size = build_string_table(translations)
|
||||
raw = decompress_string_table(table_bytes)
|
||||
|
||||
for i, trans in enumerate(translations):
|
||||
recovered = lookup_string(raw, offsets[i])
|
||||
assert recovered == trans, f"Mismatch at {i}: '{trans}' vs '{recovered}'"
|
||||
|
||||
|
||||
class TestEntryTrimming:
|
||||
"""Test entry trimming logic."""
|
||||
|
||||
def test_trim_keeps_single_stroke(self):
|
||||
"""With max_entries < total, single-stroke entries kept preferentially."""
|
||||
# Build dict: 60 single-stroke, 60 multi-stroke
|
||||
d = {}
|
||||
# Single-stroke entries (using various left-hand keys)
|
||||
single_keys = list("STKPWHRAO*EUFRPBLGTSDZ")
|
||||
for i, k in enumerate(single_keys[:15]):
|
||||
d[k] = f"word_{i}"
|
||||
|
||||
# Multi-stroke entries
|
||||
for i in range(85):
|
||||
d[f"KAT/TKOG/{i % 10}"] = f"long_translation_{i}"
|
||||
|
||||
# Total = 100 entries
|
||||
entries = list(d.items())
|
||||
assert len(entries) == 100
|
||||
|
||||
trimmed = trim_entries(entries, 50)
|
||||
assert len(trimmed) == 50
|
||||
|
||||
# Count single vs multi in result
|
||||
single_count = sum(1 for s, _ in trimmed if '/' not in s)
|
||||
multi_count = sum(1 for s, _ in trimmed if '/' in s)
|
||||
|
||||
# All 15 single-stroke entries should be kept
|
||||
assert single_count == 15
|
||||
assert multi_count == 35
|
||||
|
||||
def test_no_trim_when_under_limit(self):
|
||||
"""No trimming when entries < max_entries."""
|
||||
entries = list(SMALL_DICT.items())
|
||||
trimmed = trim_entries(entries, 1000)
|
||||
assert len(trimmed) == len(entries)
|
||||
|
||||
def test_trim_multi_stroke_by_length(self):
|
||||
"""Multi-stroke entries trimmed by translation length (shorter kept)."""
|
||||
d = {"S": "is"} # 1 single-stroke
|
||||
# Add multi-stroke with varying translation lengths
|
||||
d["KAT/S"] = "ab" # short
|
||||
d["KAT/T"] = "abcdefghij" # long
|
||||
d["KAT/K"] = "abc" # medium
|
||||
|
||||
entries = list(d.items())
|
||||
trimmed = trim_entries(entries, 3)
|
||||
|
||||
# Should keep: single("S"), then shortest multi-stroke
|
||||
assert len(trimmed) == 3
|
||||
trans = [t for _, t in trimmed]
|
||||
assert "is" in trans # single stroke kept
|
||||
assert "ab" in trans # shortest multi kept
|
||||
assert "abc" in trans # medium kept
|
||||
assert "abcdefghij" not in trans # longest dropped
|
||||
|
||||
|
||||
class TestSplitStorageFlag:
|
||||
"""Test --split-storage flag."""
|
||||
|
||||
def test_flag_set_in_header(self):
|
||||
"""split_storage flag should set bit 0 in header flags."""
|
||||
path = _make_temp_dict(SMALL_DICT)
|
||||
try:
|
||||
binary, stats, _, _, _, _ = compile_dictionary(
|
||||
path, max_entries=100, split_storage=True)
|
||||
|
||||
header = parse_header(binary)
|
||||
assert header['flags'] & FLAG_SPLIT_STORAGE != 0
|
||||
assert stats['split_storage'] is True
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_flag_not_set_by_default(self):
|
||||
"""split_storage flag should NOT be set by default."""
|
||||
path = _make_temp_dict(SMALL_DICT)
|
||||
try:
|
||||
binary, stats, _, _, _, _ = compile_dictionary(
|
||||
path, max_entries=100, split_storage=False)
|
||||
|
||||
header = parse_header(binary)
|
||||
assert header['flags'] & FLAG_SPLIT_STORAGE == 0
|
||||
assert stats['split_storage'] is False
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class TestMultiStroke:
|
||||
"""Test multi-stroke entry handling."""
|
||||
|
||||
def test_multi_stroke_compile_and_lookup(self):
|
||||
"""Multi-stroke entries (e.g. KPA/HROL) should compile and look up correctly."""
|
||||
path = _make_temp_dict(MULTI_STROKE_DICT)
|
||||
try:
|
||||
binary, stats, root, skip_cache, translations, parsed = \
|
||||
compile_dictionary(path, max_entries=100)
|
||||
|
||||
correct, wrong, missing = verify_compilation(binary, parsed, root, skip_cache)
|
||||
assert correct == len(MULTI_STROKE_DICT)
|
||||
assert wrong == 0
|
||||
assert missing == 0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_multi_stroke_traversal(self):
|
||||
"""Multi-stroke entries should appear in correct traversal order."""
|
||||
entries = _parse_and_sort(MULTI_STROKE_DICT)
|
||||
root, _, _ = build_dawg(entries)
|
||||
skip_cache = compute_skip_counts(root)
|
||||
|
||||
for strokes, _ in entries:
|
||||
idx = dawg_lookup_index(root, strokes, skip_cache)
|
||||
assert idx >= 0, f"Multi-stroke lookup failed: {strokes}"
|
||||
|
||||
def test_kpa_hrol_specific(self):
|
||||
"""KPA/HROL → {}{-|} specifically."""
|
||||
d = {"KPA/HROL": "{}{-|}"}
|
||||
entries = _parse_and_sort(d)
|
||||
root, _, _ = build_dawg(entries)
|
||||
skip_cache = compute_skip_counts(root)
|
||||
|
||||
strokes = parse_stroke_string("KPA/HROL")
|
||||
idx = dawg_lookup_index(root, strokes, skip_cache)
|
||||
assert idx == 0 # only entry → index 0
|
||||
|
||||
|
||||
class TestHeaderSerialization:
|
||||
"""Test header pack/unpack."""
|
||||
|
||||
def test_header_size(self):
|
||||
"""Header should be exactly 32 bytes."""
|
||||
header = serialize_header(0, 100, 50, 200, 1000, 500, 800)
|
||||
assert len(header) == HEADER_SIZE
|
||||
|
||||
def test_header_round_trip(self):
|
||||
"""Header fields should survive pack/unpack."""
|
||||
header = serialize_header(
|
||||
flags=FLAG_SPLIT_STORAGE,
|
||||
entry_count=12345,
|
||||
node_count=6789,
|
||||
edge_count=11111,
|
||||
string_table_offset=99999,
|
||||
string_table_size=55555,
|
||||
value_array_offset=44444,
|
||||
)
|
||||
parsed = parse_header(header)
|
||||
assert parsed['magic'] == MAGIC
|
||||
assert parsed['version'] == VERSION
|
||||
assert parsed['flags'] == FLAG_SPLIT_STORAGE
|
||||
assert parsed['entry_count'] == 12345
|
||||
assert parsed['node_count'] == 6789
|
||||
assert parsed['edge_count'] == 11111
|
||||
assert parsed['string_table_offset'] == 99999
|
||||
assert parsed['string_table_size'] == 55555
|
||||
assert parsed['value_array_offset'] == 44444
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
Loading…
Add table
Add a link
Reference in a new issue