Initial project: benchmarks, status doc, gitignore

This commit is contained in:
afiqzudinhadi 2026-06-24 16:51:44 +08:00
commit 234bc8d731
4 changed files with 2009 additions and 0 deletions

0
.gitignore vendored Normal file
View file

600
tools/alt_approaches.py Normal file
View file

@ -0,0 +1,600 @@
#!/usr/bin/env python3
"""Alternative compression approaches beyond DAWG/trie.
1. Compressed page table (sorted entries, zlib pages, binary search)
2. Rule-based reduction (phonetic rules + exception table)
3. MPHF + compressed blob (no random access, decompress per-lookup)
4. Two-level hash (first stroke → bucket → scan)
5. Full compressed blob with LRU page cache
6. Hybrid combos
"""
import json
import struct
import sys
import zlib
import math
import os
from collections import Counter, defaultdict
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
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 stroke_to_bytes(stroke_val):
"""Encode stroke as 3 bytes (23 bits used)."""
return struct.pack('<I', stroke_val)[:3]
# ─── Approach 1: Compressed Page Table ───
def test_compressed_pages(entries, page_sizes=[64, 128, 256, 512]):
"""
Sort entries, group into pages, delta-encode + zlib each page.
Binary search on page index for lookup.
"""
print("=" * 60)
print("APPROACH 1: COMPRESSED PAGE TABLE")
print("=" * 60)
# Build string table (deduped, sorted)
all_translations = sorted(set(v for _, v in entries))
trans_to_id = {t: i for i, t in enumerate(all_translations)}
raw_strings = b'\x00'.join(t.encode('utf-8') for t in all_translations)
str_compressed = len(zlib.compress(raw_strings, 9))
# Block-compressed strings for random access
str_blocks = []
for i in range(0, len(raw_strings), 4096):
str_blocks.append(zlib.compress(raw_strings[i:i+4096], 9))
str_block_total = sum(len(b) for b in str_blocks) + len(str_blocks) * 4
print(f" String table (block 4KB): {str_block_total/1024:.1f} KB")
print(f" String table (full zlib): {str_compressed/1024:.1f} KB")
print()
for page_size in page_sizes:
n_pages = math.ceil(len(entries) / page_size)
# Serialize each page: delta-coded stroke sequences + translation IDs
page_data = []
page_index = [] # (first_key, compressed_offset)
total_compressed = 0
for p in range(n_pages):
start = p * page_size
end = min(start + page_size, len(entries))
page_entries = entries[start:end]
# First key for index
first_key = page_entries[0][0]
# Serialize page
buf = bytearray()
prev_strokes = ()
for strokes, translation in page_entries:
tid = trans_to_id[translation]
# Delta from previous: shared prefix length + new strokes + tid
shared = 0
for i in range(min(len(strokes), len(prev_strokes))):
if strokes[i] == prev_strokes[i]:
shared += 1
else:
break
# Encode: shared_len(1) + n_new(1) + new_strokes(3 each) + tid(2)
n_new = len(strokes) - shared
buf.append(shared)
buf.append(n_new)
for s in strokes[shared:]:
buf.extend(stroke_to_bytes(s))
buf.extend(struct.pack('<H', tid & 0xFFFF))
prev_strokes = strokes
raw_page = bytes(buf)
compressed_page = zlib.compress(raw_page, 9)
page_index.append((first_key, total_compressed, len(compressed_page)))
total_compressed += len(compressed_page)
page_data.append(compressed_page)
# Page index size: per page = first_key (variable) + offset(3) + size(2)
avg_key_len = sum(len(k) * 3 for k, _, _ in page_index) / len(page_index)
index_entry_size = avg_key_len + 5
index_total = int(n_pages * index_entry_size)
# Simpler: first_key_hash(4) + offset(3) + size(2) = 9 bytes per page
index_simple = n_pages * 9
total = total_compressed + index_simple + str_block_total
total_fullzlib = total_compressed + index_simple + str_compressed
print(f" Page size={page_size:4d}: {n_pages:4d} pages, "
f"pages={total_compressed/1024:.1f}KB "
f"idx={index_simple/1024:.1f}KB "
f"str={str_block_total/1024:.1f}KB "
f"TOTAL={total/1024:.1f}KB "
f"(w/full zlib str: {total_fullzlib/1024:.1f}KB)")
print()
return total_compressed, index_simple, str_block_total
# ─── Approach 2: Two-Level Hash Table ───
def test_two_level_hash(entries):
"""
Level 1: hash(first_stroke) → bucket
Level 2: within bucket, linear scan of entries
Each bucket independently compressed.
"""
print("=" * 60)
print("APPROACH 2: TWO-LEVEL HASH TABLE")
print("=" * 60)
# Group by first stroke
buckets = defaultdict(list)
for strokes, translation in entries:
buckets[strokes[0]].append((strokes, translation))
print(f" Unique first strokes (buckets): {len(buckets)}")
bucket_sizes = [len(v) for v in buckets.values()]
print(f" Bucket size: min={min(bucket_sizes)} max={max(bucket_sizes)} "
f"avg={sum(bucket_sizes)/len(bucket_sizes):.1f} "
f"median={sorted(bucket_sizes)[len(bucket_sizes)//2]}")
# Build string table
all_translations = sorted(set(v for _, v in entries))
trans_to_id = {t: i for i, t in enumerate(all_translations)}
raw_strings = b'\x00'.join(t.encode('utf-8') for t in all_translations)
str_blocks = []
for i in range(0, len(raw_strings), 4096):
str_blocks.append(zlib.compress(raw_strings[i:i+4096], 9))
str_total = sum(len(b) for b in str_blocks) + len(str_blocks) * 4
# Compress each bucket
total_bucket_compressed = 0
for first_stroke, bucket_entries in buckets.items():
buf = bytearray()
for strokes, translation in sorted(bucket_entries):
tid = trans_to_id[translation]
# remaining strokes after first
remaining = strokes[1:]
buf.append(len(remaining))
for s in remaining:
buf.extend(stroke_to_bytes(s))
buf.extend(struct.pack('<H', tid & 0xFFFF))
compressed = zlib.compress(bytes(buf), 9)
total_bucket_compressed += len(compressed)
# Bucket index: first_stroke(3) + offset(3) + size(2) = 8 bytes each
bucket_index = len(buckets) * 8
total = total_bucket_compressed + bucket_index + str_total
print(f" Buckets compressed: {total_bucket_compressed/1024:.1f} KB")
print(f" Bucket index: {bucket_index/1024:.1f} KB")
print(f" String table: {str_total/1024:.1f} KB")
print(f" TOTAL: {total/1024:.1f} KB")
# With full zlib strings
str_zlib = len(zlib.compress(raw_strings, 9))
total_zlib = total_bucket_compressed + bucket_index + str_zlib
print(f" TOTAL (full zlib str): {total_zlib/1024:.1f} KB")
print()
return total
# ─── Approach 3: Full zlib blob with page cache ───
def test_full_compressed(entries):
"""
Store everything as one zlib blob. At runtime, decompress
needed sections into RAM page cache. LRU eviction.
"""
print("=" * 60)
print("APPROACH 3: FULL COMPRESSED BLOB + PAGE CACHE")
print("=" * 60)
all_translations = sorted(set(v for _, v in entries))
trans_to_id = {t: i for i, t in enumerate(all_translations)}
# Serialize all entries sorted
buf = bytearray()
prev_strokes = ()
for strokes, translation in entries:
tid = trans_to_id[translation]
shared = 0
for i in range(min(len(strokes), len(prev_strokes))):
if strokes[i] == prev_strokes[i]:
shared += 1
else:
break
n_new = len(strokes) - shared
buf.append(shared)
buf.append(n_new)
for s in strokes[shared:]:
buf.extend(stroke_to_bytes(s))
buf.extend(struct.pack('<H', tid & 0xFFFF))
prev_strokes = strokes
raw_entries = bytes(buf)
# String table
raw_strings = b'\x00'.join(t.encode('utf-8') for t in all_translations)
# Full zlib
entries_zlib = len(zlib.compress(raw_entries, 9))
strings_zlib = len(zlib.compress(raw_strings, 9))
# zstd-like (try different zlib levels and strategies)
entries_zlib_best = len(zlib.compress(raw_entries, 9))
print(f" Raw entries: {len(raw_entries)/1024:.1f} KB")
print(f" Raw strings: {len(raw_strings)/1024:.1f} KB")
print(f" Entries zlib: {entries_zlib/1024:.1f} KB")
print(f" Strings zlib: {strings_zlib/1024:.1f} KB")
print(f" TOTAL (full zlib): {(entries_zlib + strings_zlib)/1024:.1f} KB")
print()
print(f" Runtime: decompress ~4KB page per lookup (~1-5ms)")
print(f" RAM cache: 8 pages × 4KB = 32KB for hot entries")
print()
# Sectored version: split into 4KB sectors, independently compressed
# Allows decompressing only the needed sector
sector_size = 4096
sectors = []
for i in range(0, len(raw_entries), sector_size):
sector = raw_entries[i:i+sector_size]
sectors.append(zlib.compress(sector, 9))
sectors_total = sum(len(s) for s in sectors)
sector_index = len(sectors) * 8 # offset(4) + first_entry_stroke_hash(4)
str_sectors = []
for i in range(0, len(raw_strings), sector_size):
str_sectors.append(zlib.compress(raw_strings[i:i+sector_size], 9))
str_sectors_total = sum(len(s) for s in str_sectors)
str_sector_index = len(str_sectors) * 4
sectored_total = sectors_total + sector_index + str_sectors_total + str_sector_index
print(f" Sectored (4KB, random access):")
print(f" Entry sectors: {sectors_total/1024:.1f} KB ({len(sectors)} sectors)")
print(f" String sectors: {str_sectors_total/1024:.1f} KB ({len(str_sectors)} sectors)")
print(f" Indices: {(sector_index + str_sector_index)/1024:.1f} KB")
print(f" TOTAL: {sectored_total/1024:.1f} KB")
print()
return entries_zlib + strings_zlib, sectored_total
# ─── Approach 4: Phonetic rule analysis ───
def test_rule_reduction(raw_dict):
"""
Analyze how many entries could be generated by phonetic rules.
Steno maps sounds → letters. Regular words follow patterns.
"""
print("=" * 60)
print("APPROACH 4: PHONETIC RULE ANALYSIS")
print("=" * 60)
# Steno phonetic mappings (left hand → initial consonants)
LEFT_MAP = {
0x00000001: 's', # S
0x00000002: 't', # T
0x00000004: 'k', # K
0x00000002|0x00000004: 'c', # TK → d → but also 'c' context dependent
0x00000008: 'p', # P
0x00000010: 'w', # W
0x00000020: 'h', # H
0x00000040: 'r', # R
}
# Count single-stroke entries where output is a simple word
single_stroke = {k: v for k, v in raw_dict.items() if '/' not in k}
multi_stroke = {k: v for k, v in raw_dict.items() if '/' in k}
# Categorize translations
simple_words = 0 # single word, lowercase, no formatting
formatted = 0 # contains { } formatting
phrases = 0 # multiple words
other = 0
for translation in raw_dict.values():
if '{' in translation:
formatted += 1
elif ' ' in translation.strip():
phrases += 1
elif translation.strip().replace("'", "").replace("-", "").isalpha():
simple_words += 1
else:
other += 1
print(f" Translation categories:")
print(f" Simple words: {simple_words} ({simple_words/len(raw_dict)*100:.1f}%)")
print(f" Phrases: {phrases} ({phrases/len(raw_dict)*100:.1f}%)")
print(f" Formatted: {formatted} ({formatted/len(raw_dict)*100:.1f}%)")
print(f" Other: {other} ({other/len(raw_dict)*100:.1f}%)")
print()
# Suffix/prefix analysis
# Many multi-stroke entries = base word + suffix stroke
# If we store base words + suffix rules, we eliminate many entries
# Check: how many multi-stroke translations are base_word + common_suffix?
single_translations = set(raw_dict[k] for k in single_stroke)
common_suffixes = ['ing', 'ed', 'er', 'est', 'ly', 'ment', 'ness', 'tion',
'sion', 'able', 'ible', 'ful', 'less', 'ous', 'ive',
'al', 'ial', 's', 'es', "'s", 'ry', 'ary', 'ity',
'ize', 'ise', 'en', 'ence', 'ance', 'ent', 'ant',
'ion', 'or', 'ist', 'ism', 'ical', 'ically']
derivable = 0
derivable_by_suffix = Counter()
for k, v in multi_stroke.items():
v_clean = v.strip().lower()
for suffix in common_suffixes:
if v_clean.endswith(suffix):
base = v_clean[:-len(suffix)]
# Check variants of base in single-stroke dict
if base in single_translations or (base + 'e') in single_translations:
derivable += 1
derivable_by_suffix[suffix] += 1
break
print(f" Multi-stroke entries derivable from single + suffix:")
print(f" Derivable: {derivable} of {len(multi_stroke)} ({derivable/len(multi_stroke)*100:.1f}%)")
for suffix, count in derivable_by_suffix.most_common(10):
print(f" -{suffix}: {count}")
remaining = len(raw_dict) - derivable
print()
print(f" If suffix rules eliminate {derivable} entries:")
print(f" Remaining: {remaining} entries")
print(f" Reduction: {derivable/len(raw_dict)*100:.1f}%")
print()
# Word frequency: many entries are for rare words
# English has ~3000 core words covering 95% of text
# How many Plover entries map to these core words?
# Rough: count unique simple output words
word_freq = Counter()
for v in raw_dict.values():
v_clean = v.strip().lower()
if v_clean.isalpha():
word_freq[v_clean] += 1
# Top N words cover what % of entries?
total_word_entries = sum(word_freq.values())
cumulative = 0
for n, (word, count) in enumerate(word_freq.most_common()):
cumulative += count
if n + 1 in (1000, 3000, 5000, 10000, 20000):
print(f" Top {n+1:5d} words cover {cumulative/total_word_entries*100:.1f}% "
f"of word entries ({cumulative}/{total_word_entries})")
print()
return remaining
# ─── Approach 5: Multi-strategy hybrid ───
def test_hybrid(entries, raw_dict):
"""
Best of all approaches combined:
1. Phonetic rules for regular derivations (suffix combos)
2. Two-level hash for remaining entries
3. Block-compressed strings
4. LRU cache for hot pages
"""
print("=" * 60)
print("APPROACH 5: MULTI-STRATEGY HYBRID")
print("=" * 60)
# Strategy 1: Suffix rules (from approach 4)
single_stroke = {k: v for k, v in raw_dict.items() if '/' not in k}
multi_stroke = {k: v for k, v in raw_dict.items() if '/' in k}
single_translations = set(raw_dict[k] for k in single_stroke)
common_suffixes = ['ing', 'ed', 'er', 'est', 'ly', 'ment', 'ness', 'tion',
'sion', 'able', 'ible', 'ful', 'less', 'ous', 'ive',
'al', 'ial', 's', 'es', "'s"]
suffix_derivable = set()
for k, v in multi_stroke.items():
v_clean = v.strip().lower()
for suffix in common_suffixes:
if v_clean.endswith(suffix):
base = v_clean[:-len(suffix)]
if base in single_translations or (base + 'e') in single_translations:
suffix_derivable.add(k)
break
# Remaining after suffix rules
remaining_dict = {k: v for k, v in raw_dict.items() if k not in suffix_derivable}
remaining_entries = []
for stroke_str in sorted(remaining_dict.keys()):
strokes = tuple(parse_stroke(s) for s in stroke_str.split('/'))
remaining_entries.append((strokes, remaining_dict[stroke_str]))
remaining_entries.sort(key=lambda x: x[0])
n_remaining = len(remaining_entries)
# Strategy 2: Compressed page table for remaining
all_trans = sorted(set(v for _, v in remaining_entries))
trans_to_id = {t: i for i, t in enumerate(all_trans)}
raw_strings = b'\x00'.join(t.encode('utf-8') for t in all_trans)
# String table (block-compressed)
str_blocks = []
for i in range(0, len(raw_strings), 4096):
str_blocks.append(zlib.compress(raw_strings[i:i+4096], 9))
str_total = sum(len(b) for b in str_blocks) + len(str_blocks) * 4
# Entries as compressed pages
page_size = 128
n_pages = math.ceil(n_remaining / page_size)
total_pages_compressed = 0
for p in range(n_pages):
start = p * page_size
end = min(start + page_size, n_remaining)
page = remaining_entries[start:end]
buf = bytearray()
prev_strokes = ()
for strokes, translation in page:
tid = trans_to_id[translation]
shared = 0
for i in range(min(len(strokes), len(prev_strokes))):
if strokes[i] == prev_strokes[i]:
shared += 1
else:
break
n_new = len(strokes) - shared
buf.append(shared)
buf.append(n_new)
for s in strokes[shared:]:
buf.extend(stroke_to_bytes(s))
buf.extend(struct.pack('<H', tid & 0xFFFF))
prev_strokes = strokes
total_pages_compressed += len(zlib.compress(bytes(buf), 9))
page_index = n_pages * 9
# Suffix rule table: store which suffix strokes map to which suffixes
# ~20 rules × ~8 bytes = ~160 bytes
suffix_rules_size = len(common_suffixes) * 8
# Base word lookup: need to know single-stroke → translation mapping
# This is a subset of the full dict, already included in remaining_entries
# (single-stroke entries are NOT removed by suffix rules)
total_hybrid = total_pages_compressed + page_index + str_total + suffix_rules_size
print(f" Suffix rules remove: {len(suffix_derivable)} entries")
print(f" Remaining entries: {n_remaining}")
print(f" Components:")
print(f" Suffix rules: {suffix_rules_size/1024:.2f} KB")
print(f" Entry pages: {total_pages_compressed/1024:.1f} KB ({n_pages} pages)")
print(f" Page index: {page_index/1024:.1f} KB")
print(f" String table: {str_total/1024:.1f} KB")
print(f" TOTAL: {total_hybrid/1024:.1f} KB")
print()
# Full zlib strings variant
str_zlib = len(zlib.compress(raw_strings, 9))
total_hybrid_zlib = total_pages_compressed + page_index + str_zlib + suffix_rules_size
print(f" TOTAL (full zlib str): {total_hybrid_zlib/1024:.1f} KB")
print(f" (needs ~4KB RAM for decompressing string blocks)")
print()
return total_hybrid, total_hybrid_zlib
def main():
dict_path = '/tmp/plover-main.json'
with open(dict_path) as f:
raw_dict = json.load(f)
parsed = []
for stroke_str in sorted(raw_dict.keys()):
strokes = tuple(parse_stroke(s) for s in stroke_str.split('/'))
parsed.append((strokes, raw_dict[stroke_str]))
parsed.sort(key=lambda x: x[0])
print(f"Plover: {len(raw_dict)} entries")
print()
# Run all approaches
test_compressed_pages(parsed)
test_two_level_hash(parsed)
full_zlib, sectored = test_full_compressed(parsed)
remaining = test_rule_reduction(raw_dict)
hybrid, hybrid_zlib = test_hybrid(parsed, raw_dict)
# Summary
print("=" * 60)
print("FINAL SUMMARY — ALL APPROACHES")
print("=" * 60)
print(f" Target: 300 KB")
print()
results = [
("Compressed pages (128/pg)", None), # printed inline
("Two-level hash", None),
("Full zlib blob", full_zlib),
("Sectored (4KB)", sectored),
("Hybrid (suffix rules + pages)", hybrid),
("Hybrid (suffix + full zlib)", hybrid_zlib),
]
for name, size in results:
if size:
kb = size / 1024
marker = " ✓ FITS!" if kb <= 300 else f" ({kb-300:+.0f} KB over)"
print(f" {name:40s}: {kb:8.1f} KB{marker}")
if __name__ == '__main__':
main()

View file

@ -0,0 +1,687 @@
#!/usr/bin/env python3
"""Benchmark compression approaches for steno dictionaries.
Downloads Plover main.json and measures actual output sizes for:
1. Bit-packed DAWG (smhanov style)
2. MPHF + block-compressed values
3. LOUDS succinct trie
4. Computed entries (rules + exceptions)
5. Hybrid approaches
"""
import json
import struct
import sys
import zlib
import math
import os
import urllib.request
from collections import Counter, 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
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
# ─── String table compression ───
def build_string_table_raw(translations):
"""Deduplicated null-terminated strings."""
unique = sorted(set(translations))
table = b'\x00'.join(v.encode('utf-8') for v in unique)
idx_map = {}
offset = 0
for v in unique:
idx_map[v] = offset
offset += len(v.encode('utf-8')) + 1
return table, idx_map
def build_string_table_block_compressed(translations, block_size=4096):
"""Block-compressed string table with random access."""
unique = sorted(set(translations))
raw = b'\x00'.join(v.encode('utf-8') for v in unique)
blocks = []
block_offsets = []
strings_per_block = []
raw_offset = 0
for i in range(0, len(raw), block_size):
block = raw[i:i+block_size]
compressed = zlib.compress(block, 9)
block_offsets.append(len(b''.join(blocks)) if blocks else 0)
blocks.append(compressed)
count = block.count(b'\x00') + (1 if i == 0 else 0)
strings_per_block.append(count)
total_compressed = sum(len(b) for b in blocks)
index_size = len(blocks) * 4 # block offsets
cumulative_counts = len(blocks) * 4 # cumulative string counts
return total_compressed, index_size, cumulative_counts, len(unique)
def build_string_table_front_coded(translations):
"""Front-coded sorted strings."""
unique = sorted(set(translations))
total = 0
prev = b''
for v in unique:
vb = v.encode('utf-8')
shared = 0
for i in range(min(len(prev), len(vb))):
if prev[i] == vb[i]:
shared += 1
else:
break
total += 2 + len(vb) - shared # prefix_len + suffix_len + suffix
prev = vb
return total, len(unique)
# ─── Approach 1: DAWG (Daciuk algorithm) ───
class DawgNode:
next_id = 0
def __init__(self):
self.id = DawgNode.next_id
DawgNode.next_id += 1
self.edges = {} # stroke_value -> DawgNode
self.final = False
self.count = 0 # reachable end nodes
def __hash__(self):
return hash((self.final, tuple(sorted((k, v.id) for k, v in self.edges.items()))))
def __eq__(self, other):
return (self.final == other.final and
len(self.edges) == len(other.edges) and
all(k in other.edges and self.edges[k].id == other.edges[k].id
for k in self.edges))
def build_dawg(entries):
"""Build DAWG using Daciuk's algorithm. Entries must be sorted."""
DawgNode.next_id = 0
root = DawgNode()
unchecked = [] # (parent, stroke, child)
minimized = {}
prev_strokes = []
def minimize(down_to):
for i in range(len(unchecked) - 1, down_to - 1, -1):
parent, stroke, child = unchecked[i]
key = (child.final, tuple(sorted((k, v.id) for k, v in child.edges.items())))
if key in minimized:
parent.edges[stroke] = minimized[key]
else:
minimized[key] = child
unchecked.pop()
for strokes, _ in entries:
# Find common prefix
common = 0
for i in range(min(len(strokes), len(prev_strokes))):
if strokes[i] != prev_strokes[i]:
break
common += 1
else:
common = min(len(strokes), len(prev_strokes))
minimize(common)
# Add suffix
if unchecked:
node = unchecked[-1][2]
else:
node = root
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(0)
# Count reachable end nodes for each node
def count_reachable(node, visited=None):
if visited is None:
visited = {}
if node.id in visited:
return visited[node.id]
c = 1 if node.final else 0
for child in node.edges.values():
c += count_reachable(child, visited)
visited[node.id] = c
node.count = c
return c
count_reachable(root)
return root
def measure_dawg(root):
"""Count nodes, edges, measure bit-packed size."""
nodes = set()
edges = 0
fallthrough = 0
child_dist = Counter()
def visit(node):
nonlocal edges, fallthrough
if node.id in nodes:
return
nodes.add(node.id)
n_children = len(node.edges)
child_dist[n_children] += 1
edges += n_children
if n_children == 1:
fallthrough += 1
for child in node.edges.values():
visit(child)
visit(root)
return len(nodes), edges, fallthrough, child_dist
def estimate_dawg_bitpacked(n_nodes, n_edges, n_fallthrough, unique_strokes, n_entries):
"""Estimate bit-packed DAWG size (smhanov format)."""
cbits = max(1, math.ceil(math.log2(max(unique_strokes, 2))))
abits = max(1, math.ceil(math.log2(max(n_nodes, 2))))
nskipbits = max(1, math.ceil(math.log2(max(n_entries, 2))))
# Fallthrough nodes: 2 + cbits bits
ft_bits = n_fallthrough * (2 + cbits)
# Leaf nodes (0 children): 2 bits
n_leaf = sum(1 for _ in range(n_nodes) if True) # approximate
# Multi-edge nodes: 2 + 1 + n_children * (cbits + nskipbits + abits)
non_ft_edges = n_edges - n_fallthrough
multi_bits = (n_nodes - n_fallthrough) * 3 # header per non-fallthrough
multi_bits += non_ft_edges * (cbits + nskipbits + abits)
total_bits = ft_bits + multi_bits
return total_bits // 8, cbits, abits, nskipbits
# ─── Approach 2: MPHF + values ───
def estimate_mphf(n_entries, n_unique_strokes):
"""Estimate MPHF-based approach size."""
mphf_bits_per_key = 2.5 # CHD or similar
mphf_bytes = int(n_entries * mphf_bits_per_key / 8)
fingerprint_bytes = n_entries * 2 # 16-bit fingerprints
return mphf_bytes, fingerprint_bytes
# ─── Approach 3: LOUDS succinct trie ───
def build_louds_trie(entries):
"""Build trie and encode as LOUDS."""
# Build trie
class TrieNode:
__slots__ = ['children', 'is_end']
def __init__(self):
self.children = {}
self.is_end = False
root = TrieNode()
for strokes, _ in entries:
node = root
for s in strokes:
if s not in node.children:
node.children[s] = TrieNode()
node = node.children[s]
node.is_end = True
# BFS to build LOUDS
from collections import deque
queue = deque([root])
louds_bits = [] # 1 per child, 0 as separator
labels = []
is_final = []
n_nodes = 0
# Super root
louds_bits.append(1) # root is child of super root
louds_bits.append(0)
while queue:
node = queue.popleft()
n_nodes += 1
is_final.append(node.is_end)
for stroke in sorted(node.children.keys()):
louds_bits.append(1)
labels.append(stroke)
queue.append(node.children[stroke])
louds_bits.append(0) # separator
return louds_bits, labels, is_final, n_nodes
def measure_louds(louds_bits, labels, is_final, n_nodes, unique_strokes, n_entries):
"""Measure LOUDS encoding size."""
# LOUDS bitvector
louds_bytes = (len(louds_bits) + 7) // 8
# Rank/select auxiliary structures (~37.5% overhead for practical implementations)
rank_select_bytes = int(louds_bytes * 0.375)
# Labels: each label = stroke value
cbits = max(1, math.ceil(math.log2(max(unique_strokes, 2))))
labels_bytes = (len(labels) * cbits + 7) // 8
# is_final bitvector
final_bytes = (n_nodes + 7) // 8
final_rank_bytes = int(final_bytes * 0.375)
return {
'louds_bitvec': louds_bytes,
'rank_select': rank_select_bytes,
'labels': labels_bytes,
'is_final': final_bytes + final_rank_bytes,
'total': louds_bytes + rank_select_bytes + labels_bytes + final_bytes + final_rank_bytes,
}
# ─── Approach 4: Computed entries analysis ───
def analyze_computed_entries(entries_dict):
"""Analyze how many entries follow computable patterns."""
computable = 0
rule_categories = Counter()
for stroke_str, translation in entries_dict.items():
# Fingerspelling: single letter output from specific strokes
if len(translation) == 1 and translation.isalpha():
computable += 1
rule_categories['fingerspelling'] += 1
continue
# Number entries: output is digits
if translation.replace(',', '').replace('.', '').replace('-', '').isdigit():
computable += 1
rule_categories['numbers'] += 1
continue
# Simple suffix entries: {^ing}, {^ed}, {^ly}, {^er}, {^ment}, {^ness}
if translation.startswith('{^') and translation.endswith('}'):
suffix = translation[2:-1]
if suffix in ('ing', 'ed', 'ly', 'er', 'est', 'ment', 'ness', 'tion',
'sion', 'able', 'ible', 'ful', 'less', 'ous', 'ive',
'al', 'ial', 'en', 'ize', 'ise', 'ity', 'ty',
's', 'es', "'s", 'ry', 'ary'):
computable += 1
rule_categories['common_suffix'] += 1
continue
# Simple prefix entries: {pre^}, {re^}, {un^}
if translation.startswith('{') and translation.endswith('^}'):
prefix = translation[1:-2]
if prefix in ('re', 'un', 'pre', 'dis', 'mis', 'over', 'under',
'out', 'sub', 'super', 'anti', 'auto', 'bi', 'co',
'de', 'ex', 'inter', 'macro', 'micro', 'mid', 'mini',
'mono', 'multi', 'non', 'post', 'semi', 'tri'):
computable += 1
rule_categories['common_prefix'] += 1
continue
# Plover commands: {#...}, {PLOVER:...}, {MODE:...}
if translation.startswith('{#') or translation.startswith('{PLOVER:') or \
translation.startswith('{MODE:'):
computable += 1
rule_categories['commands'] += 1
continue
# Punctuation/formatting: {.}, {,}, {?}, {!}, {^}, {-|}
if translation in ('{.}', '{,}', '{?}', '{!}', '{^}', '{-|}', '{*-|}',
'{*!}', '{*?}', '{<}', '{>}', '{*<}', '{*>}',
'{^~|^}', '{~|}'):
computable += 1
rule_categories['formatting'] += 1
continue
return computable, rule_categories
# ─── Approach 5: FST-style encoding ───
def estimate_fst(entries, unique_strokes):
"""
FST shares both prefixes AND suffixes on OUTPUT side too.
Output = sequence of output tokens along edges.
"""
# In an FST, each edge carries an output fragment
# Common output prefixes/suffixes are shared
# For steno: input = stroke sequence, output = translation
# Build input trie first (same as DAWG input)
# Then attach output weights to edges
# FST minimization merges states with identical futures (like DAWG)
# PLUS merges output-compatible states
# Estimate: FST typically achieves 2-4 bytes per entry for English word lists
# For steno with longer outputs, maybe 4-8 bytes per entry
# Use BurntSushi/fst benchmarks as reference:
# 235K English words → ~750KB FST
# That's ~3.2 bytes per entry
# For steno: 147K entries, but outputs are longer (avg 8.6 chars vs 7 for English)
# Rough: 4-6 bytes per entry
low = len(entries) * 4
high = len(entries) * 6
return low, high
# ─── Main benchmark ───
def main():
# Download Plover dict
dict_path = '/tmp/plover-main.json'
if not os.path.exists(dict_path):
print("Downloading Plover main.json...")
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/openstenoproject/plover/main/plover/assets/main.json",
dict_path)
with open(dict_path) as f:
raw_dict = json.load(f)
print(f"Plover main.json: {len(raw_dict)} entries")
print()
# Parse all strokes
parsed = []
unique_stroke_vals = set()
for stroke_str in sorted(raw_dict.keys()):
strokes = tuple(parse_stroke(s) for s in stroke_str.split('/'))
for s in strokes:
unique_stroke_vals.add(s)
parsed.append((strokes, raw_dict[stroke_str]))
# Sort by stroke tuple for DAWG construction
parsed.sort(key=lambda x: x[0])
n_entries = len(parsed)
translations = [v for _, v in parsed]
unique_translations = set(translations)
n_unique_strokes = len(unique_stroke_vals)
print(f"Unique stroke values: {n_unique_strokes}")
print(f"Unique translations: {len(unique_translations)}")
print()
# ─── String table measurements ───
print("=" * 60)
print("STRING TABLE OPTIONS")
print("=" * 60)
raw_table, _ = build_string_table_raw(translations)
print(f" Raw deduplicated: {len(raw_table)/1024:8.1f} KB")
fc_size, fc_count = build_string_table_front_coded(translations)
print(f" Front-coded: {fc_size/1024:8.1f} KB")
bc_data, bc_idx, bc_cum, bc_unique = build_string_table_block_compressed(
translations, block_size=4096)
bc_total = bc_data + bc_idx + bc_cum
print(f" Block-compressed 4KB: {bc_total/1024:8.1f} KB (data={bc_data/1024:.1f} idx={bc_idx/1024:.1f})")
bc_data2, bc_idx2, bc_cum2, _ = build_string_table_block_compressed(
translations, block_size=2048)
bc_total2 = bc_data2 + bc_idx2 + bc_cum2
print(f" Block-compressed 2KB: {bc_total2/1024:8.1f} KB")
bc_data3, bc_idx3, bc_cum3, _ = build_string_table_block_compressed(
translations, block_size=8192)
bc_total3 = bc_data3 + bc_idx3 + bc_cum3
print(f" Block-compressed 8KB: {bc_total3/1024:8.1f} KB")
# Full zlib (no random access)
full_zlib = len(zlib.compress(raw_table, 9))
print(f" Full zlib (no RA): {full_zlib/1024:8.1f} KB")
# Value index: maps entry index → string table position
val_idx_2b = n_entries * 2
val_idx_3b = n_entries * 3
# With dedup: entry → unique_string_id (17 bits for 70K)
dedup_idx_bits = n_entries * math.ceil(math.log2(len(unique_translations)))
dedup_idx_bytes = (dedup_idx_bits + 7) // 8
print(f" Value index (2B/ent): {val_idx_2b/1024:8.1f} KB")
print(f" Value index (dedup): {dedup_idx_bytes/1024:8.1f} KB ({math.ceil(math.log2(len(unique_translations)))} bits/ent)")
print()
# ─── Approach 1: DAWG ───
print("=" * 60)
print("APPROACH 1: BIT-PACKED DAWG")
print("=" * 60)
print(" Building DAWG (may take ~30s)...")
root = build_dawg(parsed)
n_nodes, n_edges, n_fallthrough, child_dist = measure_dawg(root)
print(f" Nodes: {n_nodes}")
print(f" Edges: {n_edges}")
print(f" Fallthrough (1-child): {n_fallthrough}")
print(f" Suffix dedup: {(1 - n_nodes/184582)*100:.1f}% reduction from trie")
dawg_bytes, cbits, abits, nskipbits = estimate_dawg_bitpacked(
n_nodes, n_edges, n_fallthrough, n_unique_strokes, n_entries)
print(f" cbits={cbits} abits={abits} nskipbits={nskipbits}")
print(f" DAWG structure: {dawg_bytes/1024:8.1f} KB")
# DAWG gives implicit index via skip counts → no separate value index needed
# Total = DAWG + string table
dawg_total = dawg_bytes + bc_total
print(f" + block-compressed strings: {bc_total/1024:.1f} KB")
print(f" TOTAL (DAWG): {dawg_total/1024:8.1f} KB")
print()
# ─── Approach 2: MPHF ───
print("=" * 60)
print("APPROACH 2: MPHF + BLOCK-COMPRESSED VALUES")
print("=" * 60)
mphf_bytes, fp_bytes = estimate_mphf(n_entries, n_unique_strokes)
print(f" MPHF (~2.5 bits/key): {mphf_bytes/1024:8.1f} KB")
print(f" Fingerprints (16-bit): {fp_bytes/1024:8.1f} KB")
print(f" Fingerprints (8-bit): {fp_bytes/2/1024:8.1f} KB")
# Need to store stroke sequences for fingerprint verification
# Average stroke seq: 2.3 strokes × 3 bytes = 6.9 bytes per key
stroke_storage = int(n_entries * 2.3 * 3)
print(f" Stroke key storage: {stroke_storage/1024:8.1f} KB (for verification)")
mphf_total_16 = mphf_bytes + fp_bytes + bc_total + dedup_idx_bytes
mphf_total_8 = mphf_bytes + fp_bytes // 2 + bc_total + dedup_idx_bytes
mphf_total_nofp = mphf_bytes + bc_total + dedup_idx_bytes # no fingerprint, accept false positives
print(f" TOTAL (16-bit fp): {mphf_total_16/1024:8.1f} KB")
print(f" TOTAL (8-bit fp): {mphf_total_8/1024:8.1f} KB")
print(f" TOTAL (no fp): {mphf_total_nofp/1024:8.1f} KB (0.4% false positive)")
print()
# ─── Approach 3: LOUDS ───
print("=" * 60)
print("APPROACH 3: LOUDS SUCCINCT TRIE")
print("=" * 60)
print(" Building LOUDS trie...")
louds_bits, labels, is_final, louds_n_nodes = build_louds_trie(parsed)
louds_sizes = measure_louds(louds_bits, labels, is_final, louds_n_nodes,
n_unique_strokes, n_entries)
for k, v in louds_sizes.items():
if k != 'total':
print(f" {k:20s}: {v/1024:8.1f} KB")
louds_total = louds_sizes['total'] + bc_total + dedup_idx_bytes
print(f" + strings + val index: {(bc_total + dedup_idx_bytes)/1024:.1f} KB")
print(f" TOTAL (LOUDS): {louds_total/1024:8.1f} KB")
print()
# ─── Approach 4: Computed entries ───
print("=" * 60)
print("APPROACH 4: COMPUTED ENTRIES ANALYSIS")
print("=" * 60)
computable, categories = analyze_computed_entries(raw_dict)
remaining = n_entries - computable
print(f" Computable entries: {computable} ({computable/n_entries*100:.1f}%)")
for cat, count in categories.most_common():
print(f" {cat:20s}: {count}")
print(f" Remaining (stored): {remaining}")
print(f" If remaining used DAWG approach:")
reduction = remaining / n_entries
computed_dawg_est = dawg_total * reduction
print(f" Estimated: {computed_dawg_est/1024:8.1f} KB")
print()
# ─── Approach 5: FST estimate ───
print("=" * 60)
print("APPROACH 5: FST (FINITE STATE TRANSDUCER) ESTIMATE")
print("=" * 60)
fst_low, fst_high = estimate_fst(parsed, n_unique_strokes)
print(f" FST (4 bytes/entry): {fst_low/1024:8.1f} KB")
print(f" FST (6 bytes/entry): {fst_high/1024:8.1f} KB")
print(f" Note: FST stores keys + values together, no separate string table")
print()
# ─── Hybrid approaches ───
print("=" * 60)
print("HYBRID APPROACHES")
print("=" * 60)
# Hybrid 1: Computed entries + DAWG for rest
print(f"\n HYBRID 1: Computed rules + DAWG for remaining {remaining} entries")
h1_rules = 5 # KB for rule engine code
h1_dawg = dawg_total * reduction
h1_total = h1_rules * 1024 + h1_dawg
print(f" Rules engine: {h1_rules:8.1f} KB")
print(f" DAWG (remaining): {h1_dawg/1024:8.1f} KB")
print(f" TOTAL: {h1_total/1024:8.1f} KB")
# Hybrid 2: MPHF (no fingerprint) + full zlib strings + dedup index
print(f"\n HYBRID 2: MPHF + full zlib (decompress to RAM per-block)")
h2_total = mphf_bytes + bc_total + dedup_idx_bytes
print(f" MPHF: {mphf_bytes/1024:8.1f} KB")
print(f" Strings (block): {bc_total/1024:8.1f} KB")
print(f" Value index (dedup): {dedup_idx_bytes/1024:8.1f} KB")
print(f" TOTAL: {h2_total/1024:8.1f} KB")
# Hybrid 3: LOUDS trie (no value index needed - use rank on is_final)
# The rank of the final-bit gives the entry index
print(f"\n HYBRID 3: LOUDS + rank-based indexing (no value index array)")
h3_total = louds_sizes['total'] + bc_total
print(f" LOUDS structure: {louds_sizes['total']/1024:8.1f} KB")
print(f" Strings (block): {bc_total/1024:8.1f} KB")
print(f" TOTAL: {h3_total/1024:8.1f} KB")
# Hybrid 4: Computed + LOUDS for remaining
print(f"\n HYBRID 4: Computed + LOUDS for remaining {remaining}")
h4_louds_est = louds_sizes['total'] * reduction
h4_strings_est = bc_total * reduction
h4_total = h1_rules * 1024 + h4_louds_est + h4_strings_est
print(f" Rules engine: {h1_rules:8.1f} KB")
print(f" LOUDS (remaining): {h4_louds_est/1024:8.1f} KB")
print(f" Strings (remaining): {h4_strings_est/1024:8.1f} KB")
print(f" TOTAL: {h4_total/1024:8.1f} KB")
# Hybrid 5: Computed + MPHF for remaining (no fingerprint)
print(f"\n HYBRID 5: Computed + MPHF for remaining {remaining}")
h5_mphf = int(remaining * 2.5 / 8)
h5_strings = int(bc_total * reduction)
h5_dedup = int(dedup_idx_bytes * reduction)
h5_total = h1_rules * 1024 + h5_mphf + h5_strings + h5_dedup
print(f" Rules engine: {h1_rules:8.1f} KB")
print(f" MPHF (remaining): {h5_mphf/1024:8.1f} KB")
print(f" Strings (remaining): {h5_strings/1024:8.1f} KB")
print(f" Value index: {h5_dedup/1024:8.1f} KB")
print(f" TOTAL: {h5_total/1024:8.1f} KB")
# Hybrid 6: DAWG keys (implicit indexing) + zlib strings with smaller blocks
print(f"\n HYBRID 6: DAWG (implicit index) + aggressive string compression")
# Use DAWG skip-count for indexing (no value array)
# Try smaller zlib blocks for better compression at cost of more overhead
bc_data_1k, bc_idx_1k, bc_cum_1k, _ = build_string_table_block_compressed(
translations, block_size=1024)
bc_total_1k = bc_data_1k + bc_idx_1k + bc_cum_1k
print(f" DAWG structure: {dawg_bytes/1024:8.1f} KB")
print(f" Strings (1KB block): {bc_total_1k/1024:8.1f} KB")
print(f" TOTAL: {(dawg_bytes + bc_total_1k)/1024:8.1f} KB")
print()
print("=" * 60)
print("SUMMARY — ALL APPROACHES RANKED BY SIZE")
print("=" * 60)
print(f" Target: 300 KB")
print()
approaches = [
("DAWG + block strings", dawg_total),
("MPHF no-fp + strings", mphf_total_nofp),
("MPHF 8-bit fp", mphf_total_8),
("LOUDS + strings", louds_total),
("LOUDS rank-index", h3_total),
("FST (optimistic)", fst_low),
("FST (conservative)", fst_high),
("Hybrid 1: Compute+DAWG", h1_total),
("Hybrid 2: MPHF+block", h2_total),
("Hybrid 3: LOUDS+rank", h3_total),
("Hybrid 4: Compute+LOUDS", h4_total),
("Hybrid 5: Compute+MPHF", h5_total),
("Hybrid 6: DAWG+aggr.str", dawg_bytes + bc_total_1k),
]
approaches.sort(key=lambda x: x[1])
for name, size in approaches:
kb = size / 1024
marker = " ✓" if kb <= 300 else f" ({kb-300:+.0f} KB over)"
print(f" {name:28s}: {kb:8.1f} KB{marker}")
if __name__ == '__main__':
main()

722
tools/dawg_fst_prototype.py Normal file
View file

@ -0,0 +1,722 @@
#!/usr/bin/env python3
"""Prototype: DAWG with FST-style edge outputs.
DAWG handles key structure (22K nodes, 87.8% suffix sharing).
Outputs encoded directly on edges — no separate value index.
When traversing stroke sequence, accumulate output fragments from edges.
Final node's output = concatenation of all edge outputs along path.
String dedup: outputs reference into block-compressed string table.
"""
import json
import struct
import sys
import zlib
import math
import os
from collections import Counter, 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
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
# ─── DAWG with outputs ───
class DawgNode:
next_id = 0
def __init__(self):
self.id = DawgNode.next_id
DawgNode.next_id += 1
self.edges = {} # stroke_val -> (DawgNode, output_str)
self.final = False
self.final_output = "" # remaining output at final state
def signature(self):
"""Signature for minimization — includes outputs."""
edge_sig = tuple(sorted(
(k, child.id, out) for k, (child, out) in self.edges.items()
))
return (self.final, self.final_output if self.final else "", edge_sig)
def __hash__(self):
return hash(self.signature())
def __eq__(self, other):
return self.signature() == other.signature()
def build_dawg_fst(entries):
"""
Build DAWG with FST-style outputs.
For each entry (strokes, translation):
- Walk trie path for strokes
- Attach output to FIRST edge (FST convention: push output left)
- At final node, store remaining output
Then minimize: merge nodes with identical futures (including outputs).
This is a simplified FST construction — not fully optimal but
captures most of the savings.
"""
DawgNode.next_id = 0
# Phase 1: Build trie with outputs
root = DawgNode()
for strokes, translation in entries:
node = root
for i, stroke in enumerate(strokes):
if stroke not in node.edges:
new_node = DawgNode()
node.edges[stroke] = (new_node, "")
node = new_node
else:
node = node.edges[stroke][0]
node.final = True
node.final_output = translation
# Phase 2: Push outputs to edges (left-push)
# For each node, find common prefix of all outputs reachable,
# push that prefix to the incoming edge, strip from descendants.
# This enables more suffix sharing.
def push_outputs(node, depth=0):
"""Push common output prefixes toward the root."""
if not node.edges:
return
# First recurse into children
for stroke, (child, out) in list(node.edges.items()):
push_outputs(child, depth + 1)
# For each child, collect all outputs reachable from it
for stroke, (child, edge_out) in list(node.edges.items()):
if child.final and not child.edges:
# Leaf: output = edge_out + final_output
full_out = edge_out + child.final_output
node.edges[stroke] = (child, full_out)
child.final_output = ""
push_outputs(root)
# Phase 3: Minimize (merge identical subtrees including outputs)
minimized = {}
def minimize_node(node, visited=None):
if visited is None:
visited = set()
if node.id in visited:
return node
visited.add(node.id)
# First minimize children
for stroke, (child, out) in list(node.edges.items()):
minimized_child = minimize_node(child, visited)
node.edges[stroke] = (minimized_child, out)
# Check if we've seen an equivalent node
sig = node.signature()
if sig in minimized:
return minimized[sig]
minimized[sig] = node
return node
root = minimize_node(root)
return root
def measure_dawg_fst(root):
"""Measure the DAWG-FST structure."""
nodes = set()
edges = 0
total_output_bytes = 0
output_lengths = []
unique_outputs = set()
def visit(node):
nonlocal edges, total_output_bytes
if node.id in nodes:
return
nodes.add(node.id)
for stroke, (child, output) in node.edges.items():
edges += 1
out_bytes = output.encode('utf-8')
total_output_bytes += len(out_bytes)
output_lengths.append(len(out_bytes))
if output:
unique_outputs.add(output)
visit(child)
if node.final and node.final_output:
total_output_bytes += len(node.final_output.encode('utf-8'))
output_lengths.append(len(node.final_output.encode('utf-8')))
unique_outputs.add(node.final_output)
visit(root)
return {
'nodes': len(nodes),
'edges': edges,
'total_output_bytes': total_output_bytes,
'unique_outputs': len(unique_outputs),
'avg_output_len': sum(output_lengths) / max(len(output_lengths), 1),
'output_lengths': output_lengths,
}
def verify_dawg_fst(root, entries):
"""Verify lookups return correct translations."""
correct = 0
wrong = 0
missing = 0
for strokes, expected in entries:
node = root
output = ""
found = True
for stroke in strokes:
if stroke in node.edges:
child, edge_out = node.edges[stroke]
output += edge_out
node = child
else:
found = False
break
if found and node.final:
output += node.final_output
if output == expected:
correct += 1
else:
wrong += 1
if wrong <= 5:
print(f" WRONG: expected '{expected}', got '{output}'")
else:
missing += 1
if missing <= 5:
print(f" MISSING: {strokes} -> '{expected}'")
return correct, wrong, missing
def estimate_binary_size(stats, n_unique_strokes):
"""Estimate binary encoding size."""
n_nodes = stats['nodes']
n_edges = stats['edges']
cbits = max(1, math.ceil(math.log2(max(n_unique_strokes, 2))))
abits = max(1, math.ceil(math.log2(max(n_nodes, 2))))
# Edge encoding: stroke_key + target_node + output_ref
# output_ref: index into output string table
unique_outputs = stats['unique_outputs']
obits = max(1, math.ceil(math.log2(max(unique_outputs + 1, 2)))) # +1 for "no output"
bits_per_edge = cbits + abits + obits + 1 # +1 for last-edge flag
total_edge_bits = n_edges * bits_per_edge
# Node overhead: 1 bit for is_final, 1 bit for has_final_output
node_bits = n_nodes * 2
# Final outputs: nodes with final_output need an output reference
# Approximate: ~50% of final nodes have output
final_output_bits = n_nodes * obits // 4 # rough
structure_bits = total_edge_bits + node_bits + final_output_bits
structure_bytes = (structure_bits + 7) // 8
return {
'cbits': cbits,
'abits': abits,
'obits': obits,
'bits_per_edge': bits_per_edge,
'structure_bytes': structure_bytes,
}
def build_output_string_table(root):
"""Collect all unique output strings and build compressed table."""
outputs = set()
def visit(node, visited=None):
if visited is None:
visited = set()
if node.id in visited:
return
visited.add(node.id)
for stroke, (child, output) in node.edges.items():
if output:
outputs.add(output)
visit(child, visited)
if node.final and node.final_output:
outputs.add(node.final_output)
visit(root)
sorted_outputs = sorted(outputs)
# Raw
raw = b'\x00'.join(o.encode('utf-8') for o in sorted_outputs)
raw_size = len(raw)
# Block compressed
block_size = 4096
blocks = []
for i in range(0, len(raw), block_size):
block = raw[i:i+block_size]
blocks.append(zlib.compress(block, 9))
compressed_size = sum(len(b) for b in blocks)
index_size = len(blocks) * 4
# Full zlib
full_zlib_size = len(zlib.compress(raw, 9))
return {
'unique_count': len(sorted_outputs),
'raw_size': raw_size,
'block_compressed': compressed_size + index_size,
'full_zlib': full_zlib_size,
}
# ─── Alternative: DAWG keys + implicit index + compressed values ───
def build_dawg_implicit_index(entries):
"""
Standard DAWG (no outputs on edges) but with implicit indexing.
DAWG traversal counts reachable final nodes → gives entry index.
Entry index maps to value via simple array lookup.
Values stored as: entry_index → string_table_offset
String table block-compressed.
Key difference from benchmark.py: here we build actual DAWG
and measure REAL node count, then compute skip-count based size.
"""
DawgNode.next_id = 0
root = DawgNode()
unchecked = []
minimized = {}
prev_strokes = []
def minimize(down_to):
for i in range(len(unchecked) - 1, down_to - 1, -1):
parent, stroke, child = unchecked[i]
sig = (child.final, tuple(sorted((k, v.id) for k, (v, _) in child.edges.items())))
if sig in minimized:
existing = minimized[sig]
parent.edges[stroke] = (existing, "")
else:
minimized[sig] = child
unchecked.pop()
for strokes, translation in entries:
common = 0
for i in range(min(len(strokes), len(prev_strokes))):
if strokes[i] != prev_strokes[i]:
break
common += 1
else:
common = min(len(strokes), len(prev_strokes))
minimize(common)
if unchecked:
node = unchecked[-1][2]
else:
node = root
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(0)
# Count reachable finals for skip-count indexing
def count_finals(node, cache=None):
if cache is None:
cache = {}
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_finals(child, cache)
cache[node.id] = c
return c
count_finals(root)
return root
def verify_implicit_index(root, entries):
"""Verify skip-count indexing gives correct sequential indices."""
def lookup_index(node, strokes):
"""Return the skip-count index for a stroke sequence."""
idx = 0
for stroke in strokes:
# Count finals of all children with stroke < target
for s in sorted(node.edges.keys()):
if s == stroke:
child, _ = node.edges[s]
if child.final:
# This child's final state comes before its children
pass
node = child
if node.final:
idx += 1 # count this final state
break
else:
child, _ = node.edges[s]
idx += count_subtree_finals(child)
else:
return -1
return idx - 1 if node.final else -1
def count_subtree_finals(node, cache={}):
if node.id in cache:
return cache[node.id]
c = 1 if node.final else 0
for s in sorted(node.edges.keys()):
child, _ = node.edges[s]
c += count_subtree_finals(child, cache)
cache[node.id] = c
return c
# Verify first 100 entries get sequential indices
correct = 0
for expected_idx, (strokes, translation) in enumerate(entries[:100]):
got_idx = lookup_index(root, strokes)
if got_idx == expected_idx:
correct += 1
return correct
# ─── Main ───
def main():
dict_path = '/tmp/plover-main.json'
if not os.path.exists(dict_path):
print("ERROR: Download Plover dict first:")
print(" curl -sL 'https://raw.githubusercontent.com/openstenoproject/plover/main/plover/assets/main.json' -o /tmp/plover-main.json")
sys.exit(1)
with open(dict_path) as f:
raw_dict = json.load(f)
# Parse and sort
parsed = []
unique_stroke_vals = set()
for stroke_str in sorted(raw_dict.keys()):
strokes = tuple(parse_stroke(s) for s in stroke_str.split('/'))
for s in strokes:
unique_stroke_vals.add(s)
parsed.append((strokes, raw_dict[stroke_str]))
parsed.sort(key=lambda x: x[0])
n_entries = len(parsed)
n_unique_strokes = len(unique_stroke_vals)
translations = [v for _, v in parsed]
print(f"Entries: {n_entries}, Unique strokes: {n_unique_strokes}")
print()
# ─── Approach A: DAWG-FST (outputs on edges) ───
print("=" * 60)
print("APPROACH A: DAWG-FST (outputs on edges)")
print("=" * 60)
print("Building DAWG-FST...")
root_fst = build_dawg_fst(parsed)
stats = measure_dawg_fst(root_fst)
print(f" Nodes: {stats['nodes']}")
print(f" Edges: {stats['edges']}")
print(f" Unique output strings: {stats['unique_outputs']}")
print(f" Total output bytes (on edges): {stats['total_output_bytes']}")
print(f" Avg output length: {stats['avg_output_len']:.1f} bytes")
# Verify correctness
print(" Verifying lookups...")
correct, wrong, missing = verify_dawg_fst(root_fst, parsed[:1000])
print(f" Verification (first 1000): {correct} correct, {wrong} wrong, {missing} missing")
# Output string table
str_table = build_output_string_table(root_fst)
print(f" Output string table:")
print(f" Unique strings: {str_table['unique_count']}")
print(f" Raw: {str_table['raw_size']/1024:.1f} KB")
print(f" Block-compressed: {str_table['block_compressed']/1024:.1f} KB")
print(f" Full zlib: {str_table['full_zlib']/1024:.1f} KB")
# Binary size estimate
bin_est = estimate_binary_size(stats, n_unique_strokes)
print(f" Binary encoding:")
print(f" cbits={bin_est['cbits']} abits={bin_est['abits']} obits={bin_est['obits']}")
print(f" Bits/edge: {bin_est['bits_per_edge']}")
print(f" Structure: {bin_est['structure_bytes']/1024:.1f} KB")
total_a = bin_est['structure_bytes'] + str_table['block_compressed']
print(f" TOTAL: {total_a/1024:.1f} KB")
print()
# ─── Approach B: DAWG + implicit skip-count index ───
print("=" * 60)
print("APPROACH B: DAWG + skip-count index (no value array)")
print("=" * 60)
print("Building standard DAWG...")
root_std = build_dawg_implicit_index(parsed)
stats_std = measure_dawg_fst(root_std) # reuse measurement fn
print(f" Nodes: {stats_std['nodes']}")
print(f" Edges: {stats_std['edges']}")
# DAWG structure: edges need stroke + target + skip_count
cbits = max(1, math.ceil(math.log2(max(n_unique_strokes, 2))))
abits = max(1, math.ceil(math.log2(max(stats_std['nodes'], 2))))
skipbits = max(1, math.ceil(math.log2(max(n_entries, 2))))
# Compact: last-edge flag saves storing child count
bits_per_edge = cbits + abits + skipbits + 1 # stroke + target + skip + last_edge
structure_bits = stats_std['edges'] * bits_per_edge + stats_std['nodes'] * 1 # is_final per node
structure_bytes = (structure_bits + 7) // 8
print(f" cbits={cbits} abits={abits} skipbits={skipbits}")
print(f" Bits/edge: {bits_per_edge}")
print(f" Structure: {structure_bytes/1024:.1f} KB")
# Values: skip-count gives index → look up in ordered value array
# Value array: translations in DAWG traversal order
# Need: string table + offset array (index → string table position)
unique_trans = sorted(set(translations))
raw_strings = b'\x00'.join(t.encode('utf-8') for t in unique_trans)
# Dedup: entry → unique_string_id
trans_to_id = {t: i for i, t in enumerate(unique_trans)}
dedup_array = [trans_to_id[t] for t in translations]
dedup_bits = math.ceil(math.log2(len(unique_trans)))
dedup_bytes = (n_entries * dedup_bits + 7) // 8
# String table block-compressed
blocks = []
for i in range(0, len(raw_strings), 4096):
blocks.append(zlib.compress(raw_strings[i:i+4096], 9))
str_compressed = sum(len(b) for b in blocks) + len(blocks) * 4
total_b = structure_bytes + dedup_bytes + str_compressed
print(f" Value dedup array: {dedup_bytes/1024:.1f} KB ({dedup_bits} bits/entry)")
print(f" String table: {str_compressed/1024:.1f} KB")
print(f" TOTAL: {total_b/1024:.1f} KB")
print()
# ─── Approach C: DAWG + skip-count + NO dedup array ───
# Instead of dedup array, store string offset directly in DAWG final nodes
print("=" * 60)
print("APPROACH C: DAWG + skip-count + direct string refs")
print("=" * 60)
# Each final node stores a string table offset
n_final = sum(1 for _ in range(1)) # need to count
visited_c = set()
n_final_c = 0
def count_final(node):
nonlocal n_final_c
if node.id in visited_c:
return
visited_c.add(node.id)
if node.final:
n_final_c += 1
for s, (child, _) in node.edges.items():
count_final(child)
count_final(root_std)
# But wait — with DAWG suffix sharing, multiple entries share final nodes
# A shared final node can only store ONE string offset
# This breaks dedup... unless we use the skip-count to disambiguate
# Skip-count already gives unique index → use that as index into value array
# So we STILL need the value array
# Alternative: don't share final nodes (partial DAWG — share internal only)
# Then each final node = unique entry = unique string ref
print(f" Final nodes (shared): {n_final_c}")
print(f" Total entries: {n_entries}")
print(f" Final nodes can't store unique refs with suffix sharing")
print(f" → Must use skip-count index + value array (same as Approach B)")
print()
# ─── Approach D: Partial DAWG (share internal only) + direct refs ───
print("=" * 60)
print("APPROACH D: Partial DAWG (no suffix sharing at finals)")
print("=" * 60)
# Don't merge final nodes → each has unique string ref
# Merge only internal nodes
# Trade: more nodes but no value array needed
# In the standard DAWG we had 22K nodes.
# Without suffix sharing at finals, estimate:
# 147K entries = 147K unique final nodes + shared internal nodes
# Internal nodes from DAWG: ~22K - final_shared ≈ much more nodes
# This blows up the structure. Not good.
print(f" Would need ~{n_entries} final nodes (no sharing)")
print(f" Structure would be larger than value array savings")
print(f" → Not viable")
print()
# ─── Approach E: DAWG + varint value array ───
print("=" * 60)
print("APPROACH E: DAWG + varint-compressed value array")
print("=" * 60)
# Instead of fixed-width dedup_bits per entry, use varint
# Most translations are common (dedup IDs are small for frequent ones)
# Sort unique translations by frequency → frequent = small ID → small varint
trans_freq = Counter(translations)
sorted_by_freq = sorted(set(translations), key=lambda t: -trans_freq[t])
freq_to_id = {t: i for i, t in enumerate(sorted_by_freq)}
# Varint encode: 7 bits per byte, high bit = continuation
def varint_size(n):
if n < 128: return 1
if n < 16384: return 2
if n < 2097152: return 3
return 4
varint_total = sum(varint_size(freq_to_id[t]) for t in translations)
print(f" Fixed-width value array: {dedup_bytes/1024:.1f} KB")
print(f" Varint value array: {varint_total/1024:.1f} KB")
print(f" Savings: {(dedup_bytes - varint_total)/1024:.1f} KB")
total_e = structure_bytes + varint_total + str_compressed
print(f" TOTAL: {total_e/1024:.1f} KB")
print()
# ─── Approach F: Hybrid — DAWG keys + Huffman-coded values ───
print("=" * 60)
print("APPROACH F: DAWG + Huffman-coded value IDs")
print("=" * 60)
# Huffman code the dedup IDs based on frequency
# Theoretical minimum: entropy
total_entries = len(translations)
entropy_bits = 0
for t, count in trans_freq.items():
p = count / total_entries
entropy_bits -= count * math.log2(p)
entropy_bytes = int(entropy_bits / 8)
print(f" Entropy of value mapping: {entropy_bytes/1024:.1f} KB")
print(f" (theoretical minimum for value array)")
total_f = structure_bytes + entropy_bytes + str_compressed
print(f" DAWG structure: {structure_bytes/1024:.1f} KB")
print(f" Huffman values: {entropy_bytes/1024:.1f} KB")
print(f" String table: {str_compressed/1024:.1f} KB")
print(f" TOTAL: {total_f/1024:.1f} KB")
print()
# ─── Summary ───
print("=" * 60)
print("SUMMARY")
print("=" * 60)
print(f" Target: 300 KB")
print()
results = [
("A: DAWG-FST (edge outputs)", total_a),
("B: DAWG + skip + dedup array", total_b),
("E: DAWG + varint values", total_e),
("F: DAWG + Huffman values", total_f),
]
results.sort(key=lambda x: x[1])
for name, size in results:
kb = size / 1024
marker = " ✓ FITS!" if kb <= 300 else f" ({kb-300:+.0f} KB over)"
print(f" {name:40s}: {kb:8.1f} KB{marker}")
# Breakdown of best approach
print()
best_name, best_size = results[0]
print(f" Best: {best_name}")
print(f" Breakdown:")
if "FST" in best_name:
print(f" DAWG-FST structure: {bin_est['structure_bytes']/1024:.1f} KB")
print(f" Output string table: {str_table['block_compressed']/1024:.1f} KB")
elif "Huffman" in best_name:
print(f" DAWG structure: {structure_bytes/1024:.1f} KB")
print(f" Huffman value IDs: {entropy_bytes/1024:.1f} KB")
print(f" String table: {str_compressed/1024:.1f} KB")
elif "varint" in best_name:
print(f" DAWG structure: {structure_bytes/1024:.1f} KB")
print(f" Varint value array: {varint_total/1024:.1f} KB")
print(f" String table: {str_compressed/1024:.1f} KB")
if __name__ == '__main__':
main()