Initial project: benchmarks, status doc, gitignore
This commit is contained in:
commit
234bc8d731
4 changed files with 2009 additions and 0 deletions
687
tools/compression_benchmark.py
Normal file
687
tools/compression_benchmark.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue