Skip to content

Commit 310edfc

Browse files
behdadclaude
andcommitted
Drop odd-length flat dead byte; make --analyze score consistent
InnerLayer.split() padded self.data in place to make pairing work, but the flat (unsplit) solution emits self.data directly, so odd-length flat tables carried one unreachable trailing padding byte (and the reported cost, computed from the un-padded length, was one element short of the emission). Pad a local copy instead; self.data stays the original array, so the flat solution emits exactly len(data) elements. Split solutions are unchanged (pairing still uses the optimally-padded copy), so solution costs — and pick_solution's choices — are identical; only the flat array for odd-length tables loses its dead byte. Separately, `--analyze` computed the displayed Score with floor(log2) (bit_length()-1) while pick_solution uses exact log2, so the highlighted "Best solution" could disagree with the minimum-score row. Use exact log2 and show two decimals so the ranking is visibly consistent. Verified: 236 tests pass; a C+Rust round-trip fuzz over all Pareto solutions reports zero failures; regenerating HarfBuzz's tables yields byte-identical output in ~10s (the fix is a no-op for multi-level tables). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent da4bf94 commit 310edfc

3 files changed

Lines changed: 78 additions & 8 deletions

File tree

packTab/__init__.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,18 +1155,22 @@ def split(self):
11551155
11561156
When padding is needed, the padded element is never accessed
11571157
(guaranteed unreachable), so we choose a padding value that
1158-
creates the most common pair, maximizing compression.
1158+
creates the most common pair, maximizing compression. Padding is
1159+
applied to a local copy only; ``self.data`` is left untouched so the
1160+
flat (unsplit) solution emits the exact original array with no
1161+
unreachable trailing byte.
11591162
"""
1160-
if len(self.data) & 1:
1163+
data = self.data
1164+
if len(data) & 1:
11611165
# Smart padding: choose value that creates most common pair.
11621166
# The padded position is never accessed, so this is safe.
1163-
last_val = self.data[-1]
1167+
last_val = data[-1]
11641168
padding = self._choose_optimal_padding(last_val)
1165-
self.data.append(padding)
1169+
data = data + [padding]
11661170

11671171
# Collect pairs with frequencies and first occurrence positions
11681172
from collections import Counter
1169-
pairs = [(self.data[i], self.data[i + 1]) for i in range(0, len(self.data), 2)]
1173+
pairs = [(data[i], data[i + 1]) for i in range(0, len(data), 2)]
11701174
pair_freq = Counter(pairs)
11711175
first_occurrence = {}
11721176
for i, pair in enumerate(pairs):
@@ -1184,7 +1188,7 @@ def split(self):
11841188
mapping[pair] # Assigns next sequential ID
11851189

11861190
# Apply mapping to create child layer data
1187-
data2 = _combine2(self.data, lambda a, b: mapping[(a, b)])
1191+
data2 = _combine2(data, lambda a, b: mapping[(a, b)])
11881192

11891193
self.next = InnerLayer(data2)
11901194

packTab/__main__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from . import *
1616
import argparse
1717
import sys
18+
from math import log2
1819

1920

2021
def main(args=None):
@@ -205,9 +206,14 @@ def main(args=None):
205206

206207
for i, sol in enumerate(solutions):
207208
ratio = original_bytes / sol.cost if sol.cost > 0 else float("inf")
208-
score = sol.nLookups + compression_values[0] * (sol.fullCost.bit_length() - 1)
209+
# Use the same score pick_solution uses for 1..9 (exact log2, not
210+
# floor via bit_length), so the highlighted "Best solution" matches
211+
# the minimum-score row.
212+
score = sol.nLookups + compression_values[0] * (
213+
log2(sol.fullCost) if sol.fullCost > 0 else 0
214+
)
209215
print(
210-
f"{i+1:<3} {sol.nLookups:<8} {sol.nExtraOps:<9} {sol.cost:<6} {sol.fullCost:<8} {ratio:>6.2f}x {score:>7.1f}"
216+
f"{i+1:<3} {sol.nLookups:<8} {sol.nExtraOps:<9} {sol.cost:<6} {sol.fullCost:<8} {ratio:>6.2f}x {score:>8.2f}"
211217
)
212218

213219
print()

packTab/test.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,66 @@ def test_help(self):
10051005
assert r.returncode == 0
10061006
assert "packTab" in r.stdout
10071007

1008+
def test_analyze_best_matches_min_score(self):
1009+
# Regression: the displayed Score used floor(log2) while pick_solution
1010+
# used exact log2, so the highlighted "Best solution" could disagree
1011+
# with the minimum-score row. They must now agree for 1..9.
1012+
import random
1013+
1014+
rng = random.Random(555)
1015+
n = rng.randint(8, 900)
1016+
hi = rng.choice([3, 7, 15, 31, 63, 127, 255, 1023, 4095])
1017+
period = rng.choice([2, 3, 4, 5, 6, 8, 12, 16, 24, 32, 48, 64])
1018+
base = [rng.randint(0, hi) for _ in range(period)]
1019+
data = [
1020+
base[i % period] if rng.random() < rng.choice([0.7, 0.85, 0.95])
1021+
else rng.randint(0, hi)
1022+
for i in range(n)
1023+
]
1024+
r = self._run("--analyze", "--compression", "6", *[str(v) for v in data])
1025+
assert r.returncode == 0
1026+
1027+
rows, best_idx = [], None
1028+
for line in r.stdout.splitlines():
1029+
parts = line.split()
1030+
# Solution rows look like: "<idx> <lookups> <extra> <bytes>
1031+
# <fullcost> <ratio>x <score>"; the ratio column ends in 'x'.
1032+
if len(parts) == 7 and parts[0].isdigit() and parts[5].endswith("x"):
1033+
rows.append((int(parts[0]), float(parts[6])))
1034+
elif line.startswith("Best solution"):
1035+
best_idx = int(line.rsplit("#", 1)[1])
1036+
assert rows and best_idx is not None
1037+
min_idx = min(rows, key=lambda t: t[1])[0]
1038+
assert best_idx == min_idx
1039+
1040+
1041+
class TestFlatDeadByte:
1042+
"""Odd-length flat tables must not emit the split() padding byte."""
1043+
1044+
def test_flat_odd_length_has_no_padding_byte(self):
1045+
data = [255, 1, 254, 2, 253, 3, 252, 4, 251] # odd; flat is smallest
1046+
sol = pack_table(data, default=0, compression=10)
1047+
assert sol.nLookups == 1 # flat
1048+
code = Code("data")
1049+
sol.genCode(code, "get", language="c", private=False)
1050+
arrays = list(code.arrays.values())
1051+
assert arrays, "expected a real array, not an inline constant"
1052+
assert len(arrays[0].values) == len(data) # no trailing dead byte
1053+
1054+
def test_inner_split_does_not_mutate_data(self):
1055+
layer = InnerLayer([1, 2, 3, 4, 5]) # odd length
1056+
assert len(layer.data) == 5 # split() padded a copy, not self.data
1057+
1058+
def test_flat_odd_length_roundtrips(self, language):
1059+
data = [255, 1, 254, 2, 253, 3, 252, 4, 251]
1060+
sol = pack_table(data, default=0, compression=10)
1061+
lang = languageClasses[language]()
1062+
code = Code("data")
1063+
sol.genCode(code, "get", language=lang, private=False)
1064+
buf = io.StringIO()
1065+
code.print_code(file=buf, language=lang)
1066+
_compile_and_run(buf.getvalue(), data, 0, language)
1067+
10081068

10091069
class TestEdgeCases:
10101070
"""Test edge cases and boundary conditions."""

0 commit comments

Comments
 (0)