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()