Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ code.print_code(language="c")

The `pack_table` function accepts:
- A list of integers, or a dict mapping integer keys to values
- `default`: value for missing keys (default `0`); pass `None` for list
input to try both boundary values and merge the resulting Pareto frontier
- `default`: value for missing keys (default `0`). `None` is treated as a
literal value (e.g. mapped to an integer via `mapping`); pass the sentinel
`NotImplemented` for list input to infer the default from the boundary
values, trying both and merging the resulting Pareto frontier
- `compression`: tuning knob with sentinel endpoints: `0` prefers flat
encoding, `1..9` use the size/speed heuristic, and `10` minimizes raw
table bytes (default `1`)
Expand Down
40 changes: 28 additions & 12 deletions packTab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,13 @@ def print_code(
def print_c(self, file=sys.stdout, indent=0):
self.print_code(file=file, indent=indent, language="c")

def print_h(self, file=sys.stdout, linkage="", indent=0):
def print_h(self, file=sys.stdout, linkage="extern", indent=0):
"""Emit C prototypes for the public (non-private) functions.

A companion to ``print_c`` for generating a header. Private helpers
(e.g. sub-byte accessors) are ``static`` and belong with the
definitions, so they are skipped here.
"""
if linkage:
linkage += " "
if isinstance(indent, int):
Expand All @@ -665,9 +671,10 @@ def print_h(self, file=sys.stdout, linkage="", indent=0):
println = partial(printn, indent)

for name, function in self.functions.items():
link = (linkage if function.linkage is None else function.linkage) + " "
if function.private:
continue
args = ", ".join(" ".join(p) for p in function.args)
println("%s%s %s (%s);" % (link, function.retType, name, args))
println("%s%s %s (%s);" % (linkage, function.retType, name, args))


# Cost model constants. These tune the tradeoff between table size
Expand Down Expand Up @@ -1624,7 +1631,7 @@ def _try_palette_encoding(self, data, extraCost):

def pack_table(
data: Union[List[Union[int, str]], Dict[int, Union[int, str]]],
default: Optional[Union[int, str]] = 0,
default: Any = 0,
compression: Optional[float] = 1,
mapping: Optional[Dict[Any, Any]] = None,
) -> Union["OuterSolution", List["OuterSolution"]]:
Expand All @@ -1634,10 +1641,13 @@ def pack_table(
data: Either a dictionary mapping integer keys to values, or an
iterable containing values for keys starting at zero. Values must
all be integers, or all strings.
default: Value to be used for keys not specified in data. If None,
tries both boundary values and keeps the combined Pareto frontier.
Inferred defaults are only supported for list input. Defaults
to zero.
default: Value to be used for keys not specified in data (and returned
for out-of-range lookups). May be any value, including ``None``,
which is treated as a literal value (e.g. mapped to an integer via
``mapping``). Pass the sentinel ``NotImplemented`` to instead infer
the default from the boundary values, trying both and keeping the
combined Pareto frontier; inferred defaults are only supported for
list input. Defaults to zero.
compression: Tunes the size-vs-speed tradeoff. Higher values prefer
smaller tables. If None, returns all Pareto-optimal solutions.
Defaults to 1.
Expand Down Expand Up @@ -1698,8 +1708,10 @@ def pack_table(

# Set up data as a list.
if isinstance(data, dict):
if default is None:
raise ValueError("default=None is only supported for list input")
if default is NotImplemented:
raise ValueError(
"inferred default (NotImplemented) is only supported for list input"
)
if not all(isinstance(k, int) for k in data.keys()):
raise TypeError("dict keys must be integers")
minK = min(data.keys())
Expand All @@ -1720,7 +1732,11 @@ def pack_table(
raise TypeError("data values must be all integers or all non-integers")
if not isinstance(data[0], int) and mapping is not None:
data = [mapping[v] for v in data]
if not isinstance(default, int) and mapping is not None:
if (
default is not NotImplemented
and not isinstance(default, int)
and mapping is not None
):
default = mapping[default]

def build_solutions_for_default(default_value):
Expand All @@ -1746,7 +1762,7 @@ def build_solutions_for_default(default_value):

return solutions

if default is None:
if default is NotImplemented:
defaults = [data[0]]
if data[-1] != data[0]:
defaults.append(data[-1])
Expand Down
62 changes: 58 additions & 4 deletions packTab/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,29 @@ def test_print_code_public(self):
code.print_code(file=buf, language="c", private=False)
assert "extern const" in buf.getvalue()

def test_print_h_public_prototype(self):
code = Code("data")
pack_table(list(range(300)), default=0, compression=1).genCode(
code, "get", language="c", private=False
)
buf = io.StringIO()
code.print_h(file=buf)
out = buf.getvalue()
assert "extern uint16_t data_get (unsigned u);" in out
# Private sub-byte helpers must not appear in the header.
assert "data_b" not in out

def test_print_h_skips_private_helpers(self):
code = Code("t")
code.addFunction("uint8_t", "helper", (("unsigned", "u"),), "u", private=True)
code.addFunction(
"uint8_t", "pub", (("unsigned", "u"),), "u", private=False
)
buf = io.StringIO()
code.print_h(file=buf)
out = buf.getvalue()
assert "t_pub" in out and "t_helper" not in out


# ── Core algorithm ─────────────────────────────────────────────────

Expand Down Expand Up @@ -1497,10 +1520,11 @@ def test_palette_separate_from_other_arrays(self):


class TestInferredDefault:
def test_none_default_uses_boundary_candidates(self):
# Inference is requested with the NotImplemented sentinel; None is a literal.
def test_infer_uses_boundary_candidates(self):
data = [9, 1, 2, 3, 0, 0, 0]

inferred = pack_table(data, default=None, compression=None)
inferred = pack_table(data, default=NotImplemented, compression=None)
explicit = pack_table(data, default=0, compression=None)

assert any(
Expand All @@ -1509,10 +1533,10 @@ def test_none_default_uses_boundary_candidates(self):
for b in explicit
)

def test_none_default_considers_both_boundary_values(self):
def test_infer_considers_both_boundary_values(self):
data = [7, 0, 0, 0, 1]

inferred = pack_table(data, default=None, compression=None)
inferred = pack_table(data, default=NotImplemented, compression=None)
left = pack_table(data, default=7, compression=None)
right = pack_table(data, default=1, compression=None)

Expand All @@ -1524,6 +1548,36 @@ def test_none_default_considers_both_boundary_values(self):
assert inferred_costs <= candidate_costs
assert inferred_costs

def test_infer_not_supported_for_dict(self):
with pytest.raises(ValueError):
pack_table({0: 1, 5: 2}, default=NotImplemented)

def test_none_is_a_literal_value_not_inference(self):
# None must be usable as a real value (mapped to an int), the way
# HarfBuzz's decomposition table uses it: dict + default=None +
# a mapping that sends None -> 0.
mapping = {None: 0, "a": 1, "b": 2}
data = {0: "a", 3: "b"} # gaps (1, 2) fall back to default None -> 0
sol = pack_table(data, default=None, mapping=mapping, compression=1)
code = Code("data")
sol.genCode(code, "get", language="c", private=False)
buf = io.StringIO()
code.print_code(file=buf, language="c")
gen = buf.getvalue()
# Out-of-range / gap default is 0 (mapped from None), not a crash.
assert "data_get" in gen
_compile_and_run(gen, [1, 0, 0, 2], 0, "c")

def test_none_literal_list_with_mapping(self):
mapping = {None: 0, "x": 5}
data = ["x", None, "x"]
sol = pack_table(data, default=None, mapping=mapping, compression=1)
code = Code("data")
sol.genCode(code, "get", language="c", private=False)
buf = io.StringIO()
code.print_code(file=buf, language="c")
_compile_and_run(buf.getvalue(), [5, 0, 5], 0, "c")


class TestCodegenSoundnessRegression:
"""Round-trip regressions for codegen soundness bugs found by fuzzing."""
Expand Down
Loading