Skip to content

Commit 6a52aa4

Browse files
author
Ronald Tse
committed
feat(runtime): interscript-ml — the Python IMF v1 runtime (WO06)
The reference implementation Ruby/TS are diffed against: - Model.load(zip): manifest parse, every .onnx sha256-verified before the session is built (corrupt downloads fail loudly), sessions from verified bytes — no temp files - greedy KV decode when the zip ships decoder-kv.onnx, plain full-recompute fallback otherwise - tokens.py: the canonical ByT5 table (byte+3, trailing EOS) as the single source of the convention - own distribution (runtime/pyproject.toml -> 'interscript-ml'), no dependency on the training repo, torch-free - tests: tiny-graph zips for CI (load, decode, tamper rejection, tokenizer/format rejection) + an e2e golden test gated on INTERSCRIPT_ML_E2E_ZIP; verified locally against khm-latn-1.0-fp32: 100/100 golden outputs byte-identical
1 parent ccb0be0 commit 6a52aa4

8 files changed

Lines changed: 480 additions & 0 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,15 @@ jobs:
5555
pip install -e ".[dev,train,export]"
5656
- name: IMF export + parity gate fixture tests (torch vs ORT)
5757
run: PYTHONPATH=src python -m pytest tests/test_imf_export.py tests/test_imf_parity.py -v
58+
59+
python-runtime:
60+
runs-on: ubuntu-latest
61+
steps:
62+
- uses: actions/checkout@v4
63+
- uses: actions/setup-python@v5
64+
with: { python-version: "3.11" }
65+
- run: |
66+
python -m pip install --upgrade pip
67+
pip install -e "./runtime[dev]"
68+
- name: interscript-ml runtime tests (tiny-graph zips, golden e2e)
69+
run: python -m pytest runtime/tests -v

‎runtime/README.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# interscript-ml (Python runtime)
2+
3+
The reference Python runtime for **IMF v1** model zips — the phonological
4+
layer of Interscript. The Ruby (secryst gem) and TypeScript
5+
(@interscript/ml) runtimes are diffed against this one on shared golden
6+
sets.
7+
8+
```python
9+
from interscript_ml import Model
10+
11+
model = Model.load("khm-latn-1.0.zip") # sha256-verified on load
12+
model.translate("ភាសា") # -> "pheasaea"
13+
model.id # "khm-latn-1.0"
14+
```
15+
16+
- Byte-level only: the canonical ByT5 table (byte `b` → id `b+3`,
17+
trailing EOS) — no vocab files, no per-model tokenization code.
18+
- Greedy KV-cache decode when the zip ships `decoder-kv.onnx`
19+
(default), plain full-recompute fallback otherwise.
20+
- Every `.onnx` member is sha256-verified against `metadata.yaml`
21+
before the session is created; corrupt downloads fail loudly.
22+
23+
Install: `pip install ./runtime` (from the ml-models checkout) or
24+
`pip install -e "./runtime[dev]"` for development.
25+
26+
Tests: `python -m pytest runtime/tests` — tiny-graph zips, no torch
27+
needed. The end-to-end golden test runs when `INTERSCRIPT_ML_E2E_ZIP`
28+
points at a real zip (e.g. `models/khm-latn/khm-latn-1.0-fp32.zip`)
29+
and asserts byte-identical outputs against `golden/khm-latn-100.jsonl`.
30+
31+
License: BSD-3-Clause.

‎runtime/pyproject.toml‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[build-system]
2+
requires = ["setuptools>=68", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "interscript-ml"
7+
version = "0.1.0"
8+
description = "Python runtime for Interscript Model Format (IMF v1) — the phonological layer of Interscript"
9+
readme = "README.md"
10+
license = { text = "BSD-3-Clause" }
11+
requires-python = ">=3.10"
12+
authors = [{ name = "Interscript Project" }]
13+
keywords = ["transliteration", "diacritization", "g2p", "onnx", "byt5"]
14+
15+
dependencies = [
16+
"numpy>=1.26",
17+
"pyyaml>=6.0",
18+
"onnxruntime>=1.17",
19+
]
20+
21+
[project.optional-dependencies]
22+
dev = ["pytest>=8.0"]
23+
24+
[tool.setuptools.packages.find]
25+
where = ["src"]
26+
27+
[tool.pytest.ini_options]
28+
testpaths = ["tests"]
29+
pythonpath = ["src"]
30+
addopts = "-ra -q"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""interscript-ml — the Python runtime for Interscript Model Format (IMF v1).
2+
3+
The reference implementation: the Ruby and TypeScript runtimes are
4+
diffed against this one on shared golden sets.
5+
6+
from interscript_ml import Model
7+
model = Model.load("khm-latn-1.0.zip")
8+
model.translate("ភាសា") # -> "pheasaea"
9+
10+
Byte-level only: the tokenizer is the canonical ByT5 table (byte b ->
11+
token id b+3, trailing EOS), fixed and documented — no vocab files.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from interscript_ml.loader import Manifest, ModelFormatError
17+
from interscript_ml.model import Model
18+
from interscript_ml.tokens import BYTE_OFFSET, EOS_ID, PAD_ID, UNK_ID, decode, encode
19+
20+
__all__ = [
21+
"BYTE_OFFSET",
22+
"EOS_ID",
23+
"Manifest",
24+
"Model",
25+
"ModelFormatError",
26+
"PAD_ID",
27+
"UNK_ID",
28+
"decode",
29+
"encode",
30+
]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""IMF v1 zip loading: sha256 verification + extraction."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import zipfile
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
10+
import yaml
11+
12+
13+
class ModelFormatError(ValueError):
14+
"""The zip is not a valid IMF v1 artifact (or fails integrity)."""
15+
16+
17+
@dataclass(frozen=True)
18+
class Manifest:
19+
id: str
20+
task: str
21+
decoder: str
22+
precision: str
23+
opset: int
24+
sha256: dict[str, str]
25+
26+
27+
def load_manifest(zip_path: Path | str) -> Manifest:
28+
with zipfile.ZipFile(zip_path) as zf:
29+
names = zf.namelist()
30+
for required in ("metadata.yaml", "encoder.onnx", "decoder.onnx"):
31+
if required not in names:
32+
raise ModelFormatError(f"missing required file: {required}")
33+
raw = yaml.safe_load(zf.read("metadata.yaml"))
34+
if raw.get("format") != "imf-v1":
35+
raise ModelFormatError(f"unsupported format: {raw.get('format')!r}")
36+
if raw.get("tokenizer") != "bytes":
37+
raise ModelFormatError(
38+
f"tokenizer {raw.get('tokenizer')!r}: this runtime is byte-level only"
39+
)
40+
return Manifest(
41+
id=raw["id"],
42+
task=raw["task"],
43+
decoder=raw.get("decoder", "plain"),
44+
precision=raw.get("precision", "fp32"),
45+
opset=int(raw.get("opset", 14)),
46+
sha256=dict(raw.get("sha256", {})),
47+
)
48+
49+
50+
def verify_and_read(zip_path: Path | str) -> dict[str, bytes]:
51+
"""Read .onnx members after verifying each sha256 against the
52+
manifest — the corrupt-download failure mode fails loudly here."""
53+
manifest = load_manifest(zip_path)
54+
graphs: dict[str, bytes] = {}
55+
with zipfile.ZipFile(zip_path) as zf:
56+
for name in [n for n in zf.namelist() if n.endswith(".onnx")]:
57+
member = zf.read(name)
58+
recorded = manifest.sha256.get(name)
59+
if recorded is None:
60+
raise ModelFormatError(f"{name} is not covered by metadata sha256")
61+
actual = hashlib.sha256(member).hexdigest()
62+
if actual != recorded:
63+
raise ModelFormatError(
64+
f"{name} sha256 mismatch: zip has {actual}, "
65+
f"metadata says {recorded}"
66+
)
67+
graphs[name] = member
68+
return graphs
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Model.load(zip) + translate(text): greedy KV decode with plain fallback."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import numpy as np
8+
9+
from interscript_ml.tokens import EOS_ID, PAD_ID, decode, encode
10+
from interscript_ml.loader import load_manifest, verify_and_read
11+
12+
13+
class Model:
14+
"""A loaded, checksum-verified IMF v1 model.
15+
16+
>>> model = Model.load("khm-latn-1.0.zip")
17+
>>> model.translate("ភាសា")
18+
"""
19+
20+
def __init__(self, zip_path: Path | str):
21+
self.zip_path = Path(zip_path)
22+
self.manifest = load_manifest(self.zip_path)
23+
import onnxruntime as ort
24+
25+
options = ort.SessionOptions()
26+
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
27+
graphs = verify_and_read(self.zip_path)
28+
self._encoder = ort.InferenceSession(
29+
graphs["encoder.onnx"], options, providers=_providers()
30+
)
31+
decoder_name = (
32+
"decoder-kv.onnx"
33+
if self.manifest.decoder == "kv" and "decoder-kv.onnx" in graphs
34+
else "decoder.onnx"
35+
)
36+
self._kv_session = decoder_name == "decoder-kv.onnx"
37+
self._decoder = ort.InferenceSession(
38+
graphs[decoder_name], options, providers=_providers()
39+
)
40+
self._pasts = {
41+
meta.name: _zero_past(meta)
42+
for meta in self._decoder.get_inputs()
43+
if meta.name.startswith("past_")
44+
}
45+
self._output_names = [o.name for o in self._decoder.get_outputs()]
46+
47+
@classmethod
48+
def load(cls, path: Path | str) -> "Model":
49+
return cls(path)
50+
51+
@property
52+
def id(self) -> str:
53+
return self.manifest.id
54+
55+
def translate(self, text: str, max_len: int = 256) -> str:
56+
token_ids = self.generate(text, max_len=max_len)
57+
return decode(token_ids)
58+
59+
def generate(self, text: str, max_len: int = 256) -> list[int]:
60+
ids = np.array([encode(text)], dtype=np.int64)
61+
if ids.shape[1] == 1: # only the trailing EOS: empty input
62+
return []
63+
hidden = self._encoder.run(None, {"input_ids": ids})[0]
64+
if self._kv_session:
65+
return self._greedy_kv(hidden, max_len)
66+
return self._greedy_plain(hidden, max_len)
67+
68+
def _greedy_kv(self, hidden, max_len: int) -> list[int]:
69+
pasts = dict(self._pasts)
70+
current = np.array([[PAD_ID]], dtype=np.int64)
71+
generated: list[int] = []
72+
for _ in range(max_len):
73+
outputs = self._decoder.run(
74+
None,
75+
{"input_ids": current, "encoder_hidden_states": hidden, **pasts},
76+
)
77+
results = dict(zip(self._output_names, outputs, strict=True))
78+
token = int(np.argmax(results["logits"][0, -1]))
79+
if token == EOS_ID:
80+
break
81+
generated.append(token)
82+
pasts = {
83+
name: results[name.replace("past_", "present_", 1)]
84+
for name in pasts
85+
}
86+
current = np.array([[token]], dtype=np.int64)
87+
return generated
88+
89+
def _greedy_plain(self, hidden, max_len: int) -> list[int]:
90+
decoder_ids = np.array([[PAD_ID]], dtype=np.int64)
91+
generated: list[int] = []
92+
for _ in range(max_len):
93+
logits = self._decoder.run(
94+
None,
95+
{"input_ids": decoder_ids, "encoder_hidden_states": hidden},
96+
)[0]
97+
token = int(np.argmax(logits[0, -1]))
98+
if token == EOS_ID:
99+
break
100+
generated.append(token)
101+
decoder_ids = np.concatenate(
102+
[decoder_ids, np.array([[token]], dtype=np.int64)], axis=1
103+
)
104+
return generated
105+
106+
107+
def _providers() -> list[str]:
108+
import onnxruntime as ort
109+
110+
available = ort.get_available_providers()
111+
preferred = [p for p in ("CPUExecutionProvider",) if p in available]
112+
return preferred or available
113+
114+
115+
def _zero_past(meta) -> object:
116+
shape = meta.shape # [batch, heads, past_seq, d_kv], dynamic dims are str
117+
heads = shape[1] if isinstance(shape[1], int) else 4
118+
d_kv = shape[3] if isinstance(shape[3], int) else 8
119+
dtype = np.float16 if meta.type == "tensor(float16)" else np.float32
120+
return np.zeros((1, heads, 0, d_kv), dtype=dtype)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""The canonical ByT5 byte table (fixed, no vocab files).
2+
3+
Stock google/byt5 tokenizers map UTF-8 byte b to id b+3 and append
4+
EOS(1); pad=0, unk=2. Ids are NOT raw byte values — feeding text.bytes
5+
directly produces silent garbage on real models.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
BYTE_OFFSET = 3
11+
PAD_ID = 0
12+
EOS_ID = 1
13+
UNK_ID = 2
14+
15+
16+
def encode(text: str) -> list[int]:
17+
"""Canonical byte-level tokenization (byte+3 table, trailing EOS)."""
18+
return [b + BYTE_OFFSET for b in text.encode("utf-8")] + [EOS_ID]
19+
20+
21+
def decode(token_ids: list[int]) -> str:
22+
"""Token ids -> text; stops at EOS, maps id-3 back to a byte."""
23+
out = bytearray()
24+
for token in token_ids:
25+
if token == EOS_ID:
26+
break
27+
if token in (PAD_ID, UNK_ID):
28+
continue
29+
out.append((token - BYTE_OFFSET) % 256)
30+
return out.decode("utf-8", errors="replace")

0 commit comments

Comments
 (0)