ZibinDong's picture
Upload pretrained ActionCodec2 artifact
fee0e43 verified
Raw History Blame Contribute Delete
16.7 kB
"""Strict ``bpe.json`` I/O for both tokenization schemes.
The serialization layer owns only the artifact schema. Set-BPE spatial and temporal
merge semantics stay in :mod:`actioncodec2.setbpe` and plain-BPE merge
semantics in :mod:`actioncodec2.tokenization.bpe_vocabulary`, so training and deployment
cannot drift through two independent implementations of the same algorithm.
``config.set_bpe.scheme`` selects which serializer an artifact carries:
``"set_bpe"`` (the production default) or ``"bpe"``, the FAST-style baseline
that flattens the quantized cell matrix row-major before merging.
"""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from numbers import Integral
from pathlib import Path
import numpy as np
from ..common._validation import active_dimensions
from .bpe_vocabulary import BPEVocabulary
from ..physical.configuration import ActionCodec2Config, ConfigError
from .setbpe_spatial import SpatialVocabulary
from .setbpe_temporal import TemporalVocabulary
BPE_NAME = "bpe.json"
def _integer(value: object, path: str, *, positive: bool = False) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ConfigError(f"{path} must be an integer")
minimum = 1 if positive else 0
if value < minimum:
qualifier = "positive" if positive else "non-negative"
raise ConfigError(f"{path} must be {qualifier}")
return int(value)
def _rules(payload: object, width: int, path: str) -> tuple[tuple[int, ...], ...]:
"""Parse an ordered merge list without accepting lossy coercions."""
if not isinstance(payload, Sequence) or isinstance(
payload, (str, bytes, bytearray)
):
raise ConfigError(f"{path} must be a list")
output = []
for rank, raw_rule in enumerate(payload):
rule_path = f"{path}[{rank}]"
if not isinstance(raw_rule, Sequence) or isinstance(
raw_rule, (str, bytes, bytearray)
):
raise ConfigError(f"{rule_path} must be a list of {width} integers")
if len(raw_rule) != width:
raise ConfigError(f"{rule_path} must contain exactly {width} integers")
output.append(
tuple(
_integer(value, f"{rule_path}[{index}]")
for index, value in enumerate(raw_rule)
)
)
return tuple(output)
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
output: dict[str, object] = {}
for key, value in pairs:
if key in output:
raise ConfigError(f"{BPE_NAME} contains duplicate field {key!r}")
output[key] = value
return output
def _validate_rule_references(
*,
atom_count: int,
lookahead: int,
spatial_merges: tuple[tuple[int, int], ...],
temporal_merges: tuple[tuple[int, int, int], ...],
) -> None:
"""Give artifact-level errors before constructing vocabulary metadata."""
for rank, (left, right) in enumerate(spatial_merges):
child = atom_count + rank
if left >= child or right >= child:
raise ConfigError(
f"spatial_merges[{rank}] must reference already-created tokens"
)
spatial_size = atom_count + len(spatial_merges)
for rank, (left, right, offset) in enumerate(temporal_merges):
child = spatial_size + rank
if left >= child or right >= child:
raise ConfigError(
f"temporal_merges[{rank}] must reference already-created tokens"
)
if not 1 <= offset < lookahead:
raise ConfigError(
f"temporal_merges[{rank}] offset must be positive and smaller than lookahead"
)
def _read_payload(directory: str | Path) -> dict[str, object]:
"""Parse ``bpe.json`` from an artifact directory, rejecting duplicate keys."""
path = Path(directory) / BPE_NAME
try:
return json.loads(
path.read_text(encoding="utf-8"), object_pairs_hook=_unique_json_object
)
except OSError:
raise
except json.JSONDecodeError as error:
raise ConfigError(f"cannot parse {path}: {error}") from error
def _dense_cells(
cells: np.ndarray,
bins: Sequence[int],
active_dims: Sequence[int] | None,
) -> tuple[np.ndarray, tuple[int, ...] | None]:
"""Validate one trajectory and expand it to dense ``[T,D_max]`` cells.
Compact ``[T,D_active]`` input may provide explicit global ``active_dims``.
Without it, compact input uses prefix dimensions. Dense ``[T,D_max]`` input
may mark absent slots with ``-1``.
"""
raw = np.asarray(cells)
if raw.dtype.kind not in {"i", "u"}:
raise ValueError("cells must contain integer indices")
values = np.asarray(raw, dtype=np.int64)
if values.ndim != 2 or len(values) < 1 or values.shape[1] < 1:
raise ValueError("cells must have non-empty shape [T, D_active]")
if active_dims is None:
if values.shape[1] > len(bins):
raise ValueError(f"cells width exceeds codebook dimension {len(bins)}")
if values.shape[1] < len(bins):
dimensions = tuple(range(values.shape[1]))
dense = np.full((len(values), len(bins)), -1, dtype=np.int64)
dense[:, : values.shape[1]] = values
values = dense
else:
dimensions = None
else:
dimensions = active_dimensions(active_dims, len(bins))
if len(dimensions) != values.shape[1]:
raise ValueError(
"active_dims must have one entry per compact cell coordinate"
)
if np.any(values < 0):
raise ValueError("compact cells must not contain -1")
dense = np.full((len(values), len(bins)), -1, dtype=np.int64)
dense[:, dimensions] = values
values = dense
counts = np.asarray(tuple(int(value) for value in bins), dtype=np.int64)
if np.any(values < -1) or np.any(values >= counts[None, :]):
raise ValueError("cells contain an invalid active or absent index")
return values, dimensions
class SetBPESerializer:
"""Lossless mapping between ``[T,D]`` physical cells and token IDs.
Merge lists are interpreted in creation order. Token IDs are therefore
stable across physical save/load cycles, and decoding can infer any positive
trajectory length directly from stream coverage.
"""
scheme = "set_bpe"
def __init__(
self,
*,
bins: Sequence[int],
lookahead: int,
spatial_merges: Sequence[Sequence[int]],
temporal_merges: Sequence[Sequence[int]],
) -> None:
self.lookahead = _integer(lookahead, "lookahead", positive=True)
self.bins = tuple(
_integer(value, f"bins[{index}]", positive=True)
for index, value in enumerate(bins)
)
if not self.bins:
raise ConfigError("bins must contain at least one cell slot")
self.spatial_merges = _rules(spatial_merges, 2, "spatial_merges")
self.temporal_merges = _rules(temporal_merges, 3, "temporal_merges")
_validate_rule_references(
atom_count=sum(self.bins),
lookahead=self.lookahead,
spatial_merges=self.spatial_merges,
temporal_merges=self.temporal_merges,
)
try:
self.spatial = SpatialVocabulary(self.bins, self.spatial_merges)
self.vocabulary = TemporalVocabulary(
self.spatial, self.lookahead, self.temporal_merges
)
except ValueError as error:
raise ConfigError(str(error)) from error
self.patterns = self.vocabulary.patterns
self.root = self.vocabulary.root
self.vocab_size = self.vocabulary.vocab_size
self.token_cell_counts = np.asarray(
[
sum(
int(self.spatial.support[spatial_token]).bit_count()
for _, spatial_token in self.patterns[token]
)
for token in range(self.vocab_size)
],
dtype=np.int64,
)
def encode(
self,
cells: np.ndarray,
*,
active_dims: Sequence[int] | None = None,
) -> list[int]:
"""Serialize one non-empty trajectory without emitting absent slots.
Compact ``[T,D_active]`` input may provide explicit global
``active_dims``. Without it, compact input uses prefix dimensions.
Dense ``[T,D_max]`` input may mark absent slots with ``-1``.
"""
values, _ = _dense_cells(cells, self.bins, active_dims)
return self.vocabulary.encode(values)
def decode(
self,
tokens: Sequence[int],
*,
active_dims: Sequence[int] | None = None,
) -> np.ndarray:
"""Recover dense cells, writing ``-1`` in semantically absent slots."""
if len(tokens) < 1:
raise ValueError("token stream must not be empty")
checked = []
for index, token in enumerate(tokens):
if isinstance(token, bool) or not isinstance(token, Integral):
raise ValueError(f"tokens[{index}] must be an integer")
value = int(token)
if not 0 <= value < self.vocab_size:
raise ValueError(f"tokens[{index}] is outside the vocabulary")
checked.append(value)
return self.vocabulary.decode(checked, active_dims=active_dims)
def to_dict(self) -> dict[str, object]:
"""Return the intentionally minimal, path-independent BPE artifact."""
return {
"spatial_merges": [list(rule) for rule in self.spatial_merges],
"temporal_merges": [list(rule) for rule in self.temporal_merges],
}
@classmethod
def from_dict(
cls,
payload: object,
*,
bins: Sequence[int],
lookahead: int,
) -> SetBPESerializer:
if not isinstance(payload, Mapping) or not all(
isinstance(key, str) for key in payload
):
raise ConfigError(f"{BPE_NAME} must contain one JSON object")
allowed = {"spatial_merges", "temporal_merges"}
unknown = sorted(set(payload) - allowed)
if unknown:
raise ConfigError(
f"{BPE_NAME} contains unknown field(s): {', '.join(unknown)}"
)
missing = sorted(allowed - set(payload))
if missing:
raise ConfigError(f"{BPE_NAME} is missing field(s): {', '.join(missing)}")
return cls(
bins=bins,
lookahead=lookahead,
spatial_merges=payload["spatial_merges"],
temporal_merges=payload["temporal_merges"],
)
@classmethod
def from_pretrained(
cls,
directory: str | Path,
*,
bins: Sequence[int],
lookahead: int,
) -> SetBPESerializer:
return cls.from_dict(_read_payload(directory), bins=bins, lookahead=lookahead)
def save_pretrained(self, directory: str | Path) -> Path:
"""Write ``bpe.json`` and return its path."""
path = Path(directory) / BPE_NAME
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
return path
class BPESerializer:
"""Lossless mapping between ``[T,D]`` physical cells and plain-BPE ids.
The FAST-style baseline: cells are serialized row-major over the active
support and merged as an ordinary symbol sequence. It shares the primitive
atoms, the artifact layout, and the total vocabulary budget with
:class:`SetBPESerializer`, so the two can be fitted on one corpus and
compared directly.
``lookahead`` bounds a token at ``lookahead * len(bins)`` primitive cells,
the plain-BPE analogue of the Set-BPE rule that a temporal token spans at
most ``lookahead`` control steps.
"""
scheme = "bpe"
def __init__(
self,
*,
bins: Sequence[int],
lookahead: int,
merges: Sequence[Sequence[int]],
) -> None:
self.lookahead = _integer(lookahead, "lookahead", positive=True)
self.bins = tuple(
_integer(value, f"bins[{index}]", positive=True)
for index, value in enumerate(bins)
)
if not self.bins:
raise ConfigError("bins must contain at least one cell slot")
self.merges = _rules(merges, 2, "merges")
atom_count = sum(self.bins)
for rank, (left, right) in enumerate(self.merges):
child = atom_count + rank
if left >= child or right >= child:
raise ConfigError(
f"merges[{rank}] must reference already-created tokens"
)
try:
self.vocabulary = BPEVocabulary(self.bins, self.merges)
except ValueError as error:
raise ConfigError(str(error)) from error
self.max_token_cells = self.lookahead * len(self.bins)
self.token_cell_counts = self.vocabulary.token_cells
oversized = int(np.count_nonzero(self.token_cell_counts > self.max_token_cells))
if oversized:
raise ConfigError(
f"{oversized} merge rule(s) cover more than the {self.max_token_cells} "
"primitive cells allowed by lookahead"
)
self.vocab_size = self.vocabulary.vocab_size
def encode(
self,
cells: np.ndarray,
*,
active_dims: Sequence[int] | None = None,
) -> list[int]:
"""Serialize one non-empty trajectory without emitting absent slots.
Input conventions match :meth:`SetBPESerializer.encode`: compact
``[T,D_active]`` may carry explicit global ``active_dims`` and
otherwise uses prefix dimensions, while dense ``[T,D_max]`` marks
absent slots with ``-1``.
"""
values, _ = _dense_cells(cells, self.bins, active_dims)
return self.vocabulary.encode(values)
def decode(
self,
tokens: Sequence[int],
*,
active_dims: Sequence[int] | None = None,
) -> np.ndarray:
"""Recover dense cells, writing ``-1`` in semantically absent slots."""
if len(tokens) < 1:
raise ValueError("token stream must not be empty")
checked = []
for index, token in enumerate(tokens):
if isinstance(token, bool) or not isinstance(token, Integral):
raise ValueError(f"tokens[{index}] must be an integer")
value = int(token)
if not 0 <= value < self.vocab_size:
raise ValueError(f"tokens[{index}] is outside the vocabulary")
checked.append(value)
return self.vocabulary.decode(checked, active_dims=active_dims)
def to_dict(self) -> dict[str, object]:
"""Return the intentionally minimal, path-independent BPE artifact."""
return {"merges": [list(rule) for rule in self.merges]}
@classmethod
def from_dict(
cls,
payload: object,
*,
bins: Sequence[int],
lookahead: int,
) -> BPESerializer:
if not isinstance(payload, Mapping) or not all(
isinstance(key, str) for key in payload
):
raise ConfigError(f"{BPE_NAME} must contain one JSON object")
unknown = sorted(set(payload) - {"merges"})
if unknown:
raise ConfigError(
f"{BPE_NAME} contains unknown field(s): {', '.join(unknown)}"
)
if "merges" not in payload:
raise ConfigError(f"{BPE_NAME} is missing field(s): merges")
return cls(bins=bins, lookahead=lookahead, merges=payload["merges"])
@classmethod
def from_pretrained(
cls,
directory: str | Path,
*,
bins: Sequence[int],
lookahead: int,
) -> BPESerializer:
return cls.from_dict(_read_payload(directory), bins=bins, lookahead=lookahead)
def save_pretrained(self, directory: str | Path) -> Path:
"""Write ``bpe.json`` and return its path."""
path = Path(directory) / BPE_NAME
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
return path
SERIALIZERS: dict[str, type[SetBPESerializer] | type[BPESerializer]] = {
SetBPESerializer.scheme: SetBPESerializer,
BPESerializer.scheme: BPESerializer,
}
def serializer_class(
config: ActionCodec2Config,
) -> type[SetBPESerializer] | type[BPESerializer]:
"""Return the serializer the configured tokenization scheme requires."""
return SERIALIZERS[config.set_bpe.scheme]