| """Reversible lattice tokenizers with stable character and byte anchoring.""" |
|
|
| from __future__ import annotations |
|
|
| import bisect |
| import re |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Iterable, Mapping, Sequence |
|
|
| from strata.tokenization.special_tokens import DEFAULT_SPECIAL_TOKENS |
| from strata.types import AnchoredSpan |
|
|
|
|
| class TokenizerError(ValueError): |
| """Raised when tokenizer inputs or outputs violate STRATA invariants.""" |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class TokenSpan: |
| """A token with reversible text offsets.""" |
|
|
| token_index: int |
| token_id: int |
| piece: str |
| byte_start: int |
| byte_end: int |
| char_start: int |
| char_end: int |
| is_special: bool = False |
|
|
| @property |
| def byte_length(self) -> int: |
| return self.byte_end - self.byte_start |
|
|
| @property |
| def char_length(self) -> int: |
| return self.char_end - self.char_start |
|
|
| @property |
| def anchor(self) -> AnchoredSpan: |
| return AnchoredSpan.from_offsets( |
| char_start=self.char_start, |
| char_end=self.char_end, |
| byte_start=self.byte_start, |
| byte_end=self.byte_end, |
| ) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class WordSpan: |
| """Whitespace-delimited surface word span used for graph alignment.""" |
|
|
| word_index: int |
| text: str |
| byte_start: int |
| byte_end: int |
| char_start: int |
| char_end: int |
|
|
| @property |
| def anchor(self) -> AnchoredSpan: |
| return AnchoredSpan.from_offsets( |
| char_start=self.char_start, |
| char_end=self.char_end, |
| byte_start=self.byte_start, |
| byte_end=self.byte_end, |
| ) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class LatticeEncoding: |
| """Encoded text plus the span lattice needed by graph supervision.""" |
|
|
| text: str |
| input_ids: tuple[int, ...] |
| token_spans: tuple[TokenSpan, ...] |
| word_spans: tuple[WordSpan, ...] |
| char_to_byte: tuple[int, ...] |
| tokenizer_name: str |
|
|
| @property |
| def attention_mask(self) -> tuple[int, ...]: |
| return tuple(1 for _ in self.input_ids) |
|
|
| @property |
| def byte_length(self) -> int: |
| return len(self.text.encode("utf-8")) |
|
|
| @property |
| def char_length(self) -> int: |
| return len(self.text) |
|
|
| def visible_tokens(self, *, byte_end: int) -> tuple[TokenSpan, ...]: |
| """Return non-special tokens fully visible at a prefix byte boundary.""" |
|
|
| if byte_end < 0 or byte_end > self.byte_length: |
| raise TokenizerError( |
| f"byte_end must be within [0, {self.byte_length}], got {byte_end}" |
| ) |
| return tuple( |
| span |
| for span in self.token_spans |
| if not span.is_special and span.byte_end <= byte_end |
| ) |
|
|
| def token_indices_for_anchor(self, anchor: AnchoredSpan) -> tuple[int, ...]: |
| """Return token indices whose byte spans overlap an anchored graph node.""" |
|
|
| return tuple( |
| span.token_index |
| for span in self.token_spans |
| if not span.is_special |
| and span.byte_start < anchor.byte.end |
| and anchor.byte.start < span.byte_end |
| ) |
|
|
|
|
| def _validate_special_tokens(special_tokens: Sequence[str]) -> None: |
| if len(set(special_tokens)) != len(special_tokens): |
| raise TokenizerError("special tokens must be unique") |
| for token in special_tokens: |
| if not token or not token.startswith("<") or not token.endswith(">"): |
| raise TokenizerError( |
| f"special token {token!r} must use angle-bracket namespace" |
| ) |
|
|
|
|
| def _char_to_byte_offsets(text: str) -> tuple[int, ...]: |
| offsets = [0] |
| byte_position = 0 |
| for char in text: |
| byte_position += len(char.encode("utf-8")) |
| offsets.append(byte_position) |
| return tuple(offsets) |
|
|
|
|
| def _char_span_to_byte_span( |
| char_start: int, |
| char_end: int, |
| char_to_byte: Sequence[int], |
| ) -> tuple[int, int]: |
| text_length = len(char_to_byte) - 1 |
| if char_start < 0 or char_end < char_start or char_end > text_length: |
| raise TokenizerError( |
| f"invalid char span [{char_start}, {char_end}) for text length " |
| f"{text_length}" |
| ) |
| return char_to_byte[char_start], char_to_byte[char_end] |
|
|
|
|
| def _byte_span_to_char_span( |
| byte_start: int, |
| byte_end: int, |
| char_to_byte: Sequence[int], |
| ) -> tuple[int, int]: |
| if byte_start < 0 or byte_end < byte_start or byte_end > char_to_byte[-1]: |
| raise TokenizerError( |
| f"invalid byte span [{byte_start}, {byte_end}) for byte length " |
| f"{char_to_byte[-1]}" |
| ) |
| if byte_start == byte_end: |
| char = bisect.bisect_right(char_to_byte, byte_start) - 1 |
| char = max(0, min(char, len(char_to_byte) - 1)) |
| return char, char |
|
|
| char_start = bisect.bisect_right(char_to_byte, byte_start) - 1 |
| char_end = bisect.bisect_left(char_to_byte, byte_end) |
| if char_end <= char_start: |
| char_end = char_start + 1 |
| return char_start, min(char_end, len(char_to_byte) - 1) |
|
|
|
|
| _WORD_PATTERN = re.compile(r"\S+") |
|
|
|
|
| def _word_spans(text: str, char_to_byte: Sequence[int]) -> tuple[WordSpan, ...]: |
| spans: list[WordSpan] = [] |
| for word_index, match in enumerate(_WORD_PATTERN.finditer(text)): |
| byte_start, byte_end = _char_span_to_byte_span( |
| match.start(), match.end(), char_to_byte |
| ) |
| spans.append( |
| WordSpan( |
| word_index=word_index, |
| text=match.group(0), |
| byte_start=byte_start, |
| byte_end=byte_end, |
| char_start=match.start(), |
| char_end=match.end(), |
| ) |
| ) |
| return tuple(spans) |
|
|
|
|
| class ByteLatticeTokenizer: |
| """UTF-8 byte tokenizer that always roundtrips and preserves spans. |
| |
| This tokenizer is intentionally simple and production-safe. It is a reliable |
| fallback before a trained SentencePiece model exists, and it is useful for |
| debugging graph alignment because every byte position is represented. |
| """ |
|
|
| name = "strata-byte-lattice" |
|
|
| def __init__(self, special_tokens: Sequence[str] = DEFAULT_SPECIAL_TOKENS) -> None: |
| _validate_special_tokens(special_tokens) |
| self.special_tokens = tuple(special_tokens) |
| self.special_to_id = {token: idx for idx, token in enumerate(special_tokens)} |
| self.id_to_special = {idx: token for token, idx in self.special_to_id.items()} |
| self.byte_offset = len(self.special_tokens) |
| self.vocab_size = self.byte_offset + 256 |
|
|
| @property |
| def pad_token_id(self) -> int: |
| return self.special_to_id["<PAD>"] |
|
|
| @property |
| def bos_token_id(self) -> int: |
| return self.special_to_id["<BOS>"] |
|
|
| @property |
| def eos_token_id(self) -> int: |
| return self.special_to_id["<EOS>"] |
|
|
| def token_to_id(self, token: str) -> int: |
| if token in self.special_to_id: |
| return self.special_to_id[token] |
| if re.fullmatch(r"<0x[0-9A-Fa-f]{2}>", token): |
| return self.byte_offset + int(token[3:5], 16) |
| raise TokenizerError(f"unknown token {token!r}") |
|
|
| def id_to_token(self, token_id: int) -> str: |
| if token_id in self.id_to_special: |
| return self.id_to_special[token_id] |
| if self.byte_offset <= token_id < self.byte_offset + 256: |
| return f"<0x{token_id - self.byte_offset:02X}>" |
| raise TokenizerError(f"token id {token_id} is outside vocab size {self.vocab_size}") |
|
|
| def encode( |
| self, |
| text: str, |
| *, |
| add_bos: bool = False, |
| add_eos: bool = False, |
| ) -> LatticeEncoding: |
| if not isinstance(text, str): |
| raise TypeError(f"text must be str, got {type(text).__name__}") |
|
|
| char_to_byte = _char_to_byte_offsets(text) |
| data = text.encode("utf-8") |
| input_ids: list[int] = [] |
| token_spans: list[TokenSpan] = [] |
|
|
| def append_special(token: str) -> None: |
| token_id = self.special_to_id[token] |
| token_spans.append( |
| TokenSpan( |
| token_index=len(input_ids), |
| token_id=token_id, |
| piece=token, |
| byte_start=0, |
| byte_end=0, |
| char_start=0, |
| char_end=0, |
| is_special=True, |
| ) |
| ) |
| input_ids.append(token_id) |
|
|
| if add_bos: |
| append_special("<BOS>") |
|
|
| for byte_index, byte_value in enumerate(data): |
| char_start, char_end = _byte_span_to_char_span( |
| byte_index, byte_index + 1, char_to_byte |
| ) |
| token_id = self.byte_offset + byte_value |
| token_spans.append( |
| TokenSpan( |
| token_index=len(input_ids), |
| token_id=token_id, |
| piece=f"<0x{byte_value:02X}>", |
| byte_start=byte_index, |
| byte_end=byte_index + 1, |
| char_start=char_start, |
| char_end=char_end, |
| ) |
| ) |
| input_ids.append(token_id) |
|
|
| if add_eos: |
| append_special("<EOS>") |
|
|
| return LatticeEncoding( |
| text=text, |
| input_ids=tuple(input_ids), |
| token_spans=tuple(token_spans), |
| word_spans=_word_spans(text, char_to_byte), |
| char_to_byte=char_to_byte, |
| tokenizer_name=self.name, |
| ) |
|
|
| def decode( |
| self, |
| input_ids: Iterable[int], |
| *, |
| skip_special_tokens: bool = True, |
| errors: str = "strict", |
| ) -> str: |
| data = bytearray() |
| parts: list[str] = [] |
|
|
| def flush_data() -> None: |
| if data: |
| parts.append(bytes(data).decode("utf-8", errors=errors)) |
| data.clear() |
|
|
| for token_id in input_ids: |
| if self.byte_offset <= token_id < self.byte_offset + 256: |
| data.append(token_id - self.byte_offset) |
| elif token_id in self.id_to_special: |
| if not skip_special_tokens: |
| flush_data() |
| parts.append(self.id_to_special[token_id]) |
| else: |
| raise TokenizerError( |
| f"token id {token_id} is outside vocab size {self.vocab_size}" |
| ) |
| flush_data() |
| return "".join(parts) |
|
|
|
|
| class SentencePieceLatticeTokenizer: |
| """SentencePiece tokenizer wrapper that preserves proto-provided offsets.""" |
|
|
| name = "strata-sentencepiece-lattice" |
|
|
| def __init__( |
| self, |
| model_file: str | Path, |
| *, |
| special_tokens: Sequence[str] = DEFAULT_SPECIAL_TOKENS, |
| require_exact_roundtrip: bool = True, |
| ) -> None: |
| try: |
| import sentencepiece as spm |
| except ImportError as exc: |
| raise TokenizerError("sentencepiece is required for this tokenizer") from exc |
|
|
| _validate_special_tokens(special_tokens) |
| self.model_file = Path(model_file) |
| if not self.model_file.exists(): |
| raise TokenizerError(f"SentencePiece model does not exist: {self.model_file}") |
|
|
| self.processor = spm.SentencePieceProcessor(model_file=str(self.model_file)) |
| self.special_tokens = tuple(special_tokens) |
| self.special_to_id = {token: idx for idx, token in enumerate(special_tokens)} |
| self.id_to_special = {idx: token for token, idx in self.special_to_id.items()} |
| self.sp_offset = len(self.special_tokens) |
| self.sp_vocab_size = int(self.processor.vocab_size()) |
| self.vocab_size = self.sp_offset + self.sp_vocab_size |
| self.require_exact_roundtrip = require_exact_roundtrip |
|
|
| @property |
| def pad_token_id(self) -> int: |
| return self.special_to_id["<PAD>"] |
|
|
| @property |
| def bos_token_id(self) -> int: |
| return self.special_to_id["<BOS>"] |
|
|
| @property |
| def eos_token_id(self) -> int: |
| return self.special_to_id["<EOS>"] |
|
|
| def encode( |
| self, |
| text: str, |
| *, |
| add_bos: bool = False, |
| add_eos: bool = False, |
| ) -> LatticeEncoding: |
| if not isinstance(text, str): |
| raise TypeError(f"text must be str, got {type(text).__name__}") |
|
|
| char_to_byte = _char_to_byte_offsets(text) |
| |
| |
| |
| try: |
| encoded = self.processor.Encode(text, return_type="offset_mapping") |
| proto_pieces = zip( |
| encoded["ids"], encoded["pieces"], encoded["offsets"], strict=True |
| ) |
| except (TypeError, ValueError): |
| proto = self.processor.EncodeAsImmutableProto(text) |
| proto_pieces = ( |
| (piece.id, piece.piece, (piece.begin, piece.end)) for piece in proto.pieces |
| ) |
| input_ids: list[int] = [] |
| token_spans: list[TokenSpan] = [] |
|
|
| def append_special(token: str) -> None: |
| token_id = self.special_to_id[token] |
| token_spans.append( |
| TokenSpan( |
| token_index=len(input_ids), |
| token_id=token_id, |
| piece=token, |
| byte_start=0, |
| byte_end=0, |
| char_start=0, |
| char_end=0, |
| is_special=True, |
| ) |
| ) |
| input_ids.append(token_id) |
|
|
| if add_bos: |
| append_special("<BOS>") |
|
|
| for piece_id, piece_text, offsets in proto_pieces: |
| char_start, char_end = (int(value) for value in offsets) |
| byte_start, byte_end = _char_span_to_byte_span( |
| char_start, char_end, char_to_byte |
| ) |
| token_id = self.sp_offset + int(piece_id) |
| token_spans.append( |
| TokenSpan( |
| token_index=len(input_ids), |
| token_id=token_id, |
| piece=str(piece_text), |
| byte_start=byte_start, |
| byte_end=byte_end, |
| char_start=char_start, |
| char_end=char_end, |
| ) |
| ) |
| input_ids.append(token_id) |
|
|
| if add_eos: |
| append_special("<EOS>") |
|
|
| if self.require_exact_roundtrip: |
| decoded = self.decode(input_ids) |
| if decoded != text: |
| raise TokenizerError( |
| "SentencePiece model is not exact-roundtrip for this text; " |
| "train with identity normalization and preserved whitespace. " |
| f"decoded={decoded!r}, original={text!r}" |
| ) |
|
|
| return LatticeEncoding( |
| text=text, |
| input_ids=tuple(input_ids), |
| token_spans=tuple(token_spans), |
| word_spans=_word_spans(text, char_to_byte), |
| char_to_byte=char_to_byte, |
| tokenizer_name=self.name, |
| ) |
|
|
| def decode( |
| self, |
| input_ids: Iterable[int], |
| *, |
| skip_special_tokens: bool = True, |
| ) -> str: |
| sp_ids: list[int] = [] |
| parts: list[str] = [] |
|
|
| def flush_sp_ids() -> None: |
| if sp_ids: |
| parts.append(self.processor.DecodeIds(sp_ids)) |
| sp_ids.clear() |
|
|
| for token_id in input_ids: |
| if self.sp_offset <= token_id < self.sp_offset + self.sp_vocab_size: |
| sp_ids.append(token_id - self.sp_offset) |
| elif token_id in self.id_to_special: |
| if not skip_special_tokens: |
| flush_sp_ids() |
| parts.append(self.id_to_special[token_id]) |
| else: |
| raise TokenizerError( |
| f"token id {token_id} is outside vocab size {self.vocab_size}" |
| ) |
| flush_sp_ids() |
| return "".join(parts) |
|
|
|
|
| def special_token_ids(tokenizer: ByteLatticeTokenizer | SentencePieceLatticeTokenizer) -> Mapping[str, int]: |
| """Return a copy of the tokenizer's reserved-token ID mapping.""" |
|
|
| return dict(tokenizer.special_to_id) |
|
|