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
12 changes: 12 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,15 @@ jobs:
pip install -e ".[dev,train,export]"
- name: IMF export + parity gate fixture tests (torch vs ORT)
run: PYTHONPATH=src python -m pytest tests/test_imf_export.py tests/test_imf_parity.py -v

python-runtime:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: |
python -m pip install --upgrade pip
pip install -e "./runtime[dev]"
- name: interscript-ml runtime tests (tiny-graph zips, golden e2e)
run: python -m pytest runtime/tests -v
32 changes: 32 additions & 0 deletions models.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# interscript-ml model index — the stable URL every runtime resolves
# model ids against (Ruby / TypeScript / Python implement the same
# algorithm; this file is the contract).
#
# Resolution algorithm (identical in all runtimes):
# 1. resolve `id` in models.models
# 2. prefer an installed cache copy at <cache_dir>/<id>/<filename>
# whose whole-file sha256 matches `sha256`
# 3. else download `url` to a temp file in the same directory,
# verify sha256, atomically rename into place
# 4. load the zip (IMF v1: member sha256 verification on load)
#
# Overrides: INTERSCRIPT_ML_INDEX (URL or path to an index like this one),
# INTERSCRIPT_ML_CACHE (cache directory; default ~/.cache/interscript).
#
# Adding a model: it must have passed the WO03 gate (strict validation,
# parity written into the zip) before an entry ships here.
version: 1
models:
khm-latn-1.0:
task: translit
scripts: [Khmr, Latn]
precision: fp32
filename: khm-latn-1.0-fp32.zip
url: https://github.com/interscript/ml-models/releases/download/khm-latn-1.0/khm-latn-1.0-fp32.zip
sha256: 55993d473a2ed9489058779cad7e115db05e4020085b71616468cfae4f2f65cb
size: 1418009977
metrics:
- {name: cer, value: 27.42, source: secryst/docs/RESULTS.md#khmer-transliteration-2026-08-14}
- {name: em, value: 59.66, source: secryst/docs/RESULTS.md#khmer-transliteration-2026-08-14}
parity: {samples: 895, cer_delta: 0.0}
license: BSD-3-Clause
39 changes: 39 additions & 0 deletions runtime/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# interscript-ml (Python runtime)

The reference Python runtime for **IMF v1** model zips — the phonological
layer of Interscript. The Ruby (secryst gem) and TypeScript
(@interscript/ml) runtimes are diffed against this one on shared golden
sets.

```python
from interscript_ml import Model

model = Model.load("khm-latn-1.0") # id: index resolve -> download
# -> sha256-verify -> cache -> load
model.translate("ភាសា") # -> "pheasaea"
model.id # "khm-latn-1.0"

model = Model.load("khm-latn-1.0.zip") # or: a local zip path directly
```

- Byte-level only: the canonical ByT5 table (byte `b` → id `b+3`,
trailing EOS) — no vocab files, no per-model tokenization code.
- Greedy KV-cache decode when the zip ships `decoder-kv.onnx`
(default), plain full-recompute fallback otherwise.
- Every `.onnx` member is sha256-verified against `metadata.yaml`
before the session is created; corrupt downloads fail loudly.
- Dynamic fetch per the `models.yaml` contract (shared with the Ruby and
TypeScript runtimes): resolve id -> channel URL, download to temp,
verify whole-file sha256 against the index, atomically install into
`~/.cache/interscript/models/<id>/`. Overrides:
`INTERSCRIPT_ML_INDEX` (URL or path), `INTERSCRIPT_ML_CACHE`.

Install: `pip install ./runtime` (from the ml-models checkout) or
`pip install -e "./runtime[dev]"` for development.

Tests: `python -m pytest runtime/tests` — tiny-graph zips, no torch
needed. The end-to-end golden test runs when `INTERSCRIPT_ML_E2E_ZIP`
points at a real zip (e.g. `models/khm-latn/khm-latn-1.0-fp32.zip`)
and asserts byte-identical outputs against `golden/khm-latn-100.jsonl`.

License: BSD-3-Clause.
30 changes: 30 additions & 0 deletions runtime/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "interscript-ml"
version = "0.1.0"
description = "Python runtime for Interscript Model Format (IMF v1) — the phonological layer of Interscript"
readme = "README.md"
license = { text = "BSD-3-Clause" }
requires-python = ">=3.10"
authors = [{ name = "Interscript Project" }]
keywords = ["transliteration", "diacritization", "g2p", "onnx", "byt5"]

dependencies = [
"numpy>=1.26",
"pyyaml>=6.0",
"onnxruntime>=1.17",
]

[project.optional-dependencies]
dev = ["pytest>=8.0", "onnx>=1.16"]

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-ra -q"
33 changes: 33 additions & 0 deletions runtime/src/interscript_ml/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""interscript-ml — the Python runtime for Interscript Model Format (IMF v1).

The reference implementation: the Ruby and TypeScript runtimes are
diffed against this one on shared golden sets.

from interscript_ml import Model
model = Model.load("khm-latn-1.0.zip")
model.translate("ភាសា") # -> "pheasaea"

Byte-level only: the tokenizer is the canonical ByT5 table (byte b ->
token id b+3, trailing EOS), fixed and documented — no vocab files.
"""

from __future__ import annotations

from interscript_ml.loader import Manifest, ModelFormatError
from interscript_ml.model import Model
from interscript_ml.registry import RegistryError, resolve
from interscript_ml.tokens import BYTE_OFFSET, EOS_ID, PAD_ID, UNK_ID, decode, encode

__all__ = [
"BYTE_OFFSET",
"EOS_ID",
"Manifest",
"Model",
"ModelFormatError",
"PAD_ID",
"RegistryError",
"UNK_ID",
"decode",
"encode",
"resolve",
]
68 changes: 68 additions & 0 deletions runtime/src/interscript_ml/loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""IMF v1 zip loading: sha256 verification + extraction."""

from __future__ import annotations

import hashlib
import zipfile
from dataclasses import dataclass
from pathlib import Path

import yaml


class ModelFormatError(ValueError):
"""The zip is not a valid IMF v1 artifact (or fails integrity)."""


@dataclass(frozen=True)
class Manifest:
id: str
task: str
decoder: str
precision: str
opset: int
sha256: dict[str, str]


def load_manifest(zip_path: Path | str) -> Manifest:
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
for required in ("metadata.yaml", "encoder.onnx", "decoder.onnx"):
if required not in names:
raise ModelFormatError(f"missing required file: {required}")
raw = yaml.safe_load(zf.read("metadata.yaml"))
if raw.get("format") != "imf-v1":
raise ModelFormatError(f"unsupported format: {raw.get('format')!r}")
if raw.get("tokenizer") != "bytes":
raise ModelFormatError(
f"tokenizer {raw.get('tokenizer')!r}: this runtime is byte-level only"
)
return Manifest(
id=raw["id"],
task=raw["task"],
decoder=raw.get("decoder", "plain"),
precision=raw.get("precision", "fp32"),
opset=int(raw.get("opset", 14)),
sha256=dict(raw.get("sha256", {})),
)


def verify_and_read(zip_path: Path | str) -> dict[str, bytes]:
"""Read .onnx members after verifying each sha256 against the
manifest — the corrupt-download failure mode fails loudly here."""
manifest = load_manifest(zip_path)
graphs: dict[str, bytes] = {}
with zipfile.ZipFile(zip_path) as zf:
for name in [n for n in zf.namelist() if n.endswith(".onnx")]:
member = zf.read(name)
recorded = manifest.sha256.get(name)
if recorded is None:
raise ModelFormatError(f"{name} is not covered by metadata sha256")
actual = hashlib.sha256(member).hexdigest()
if actual != recorded:
raise ModelFormatError(
f"{name} sha256 mismatch: zip has {actual}, "
f"metadata says {recorded}"
)
graphs[name] = member
return graphs
127 changes: 127 additions & 0 deletions runtime/src/interscript_ml/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Model.load(zip) + translate(text): greedy KV decode with plain fallback."""

from __future__ import annotations

from pathlib import Path

import numpy as np

from interscript_ml.tokens import EOS_ID, PAD_ID, decode, encode
from interscript_ml.loader import load_manifest, verify_and_read


class Model:
"""A loaded, checksum-verified IMF v1 model.

>>> model = Model.load("khm-latn-1.0.zip")
>>> model.translate("ភាសា")
"""

def __init__(self, zip_path: Path | str):
self.zip_path = Path(zip_path)
self.manifest = load_manifest(self.zip_path)
import onnxruntime as ort

options = ort.SessionOptions()
options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
graphs = verify_and_read(self.zip_path)
self._encoder = ort.InferenceSession(
graphs["encoder.onnx"], options, providers=_providers()
)
decoder_name = (
"decoder-kv.onnx"
if self.manifest.decoder == "kv" and "decoder-kv.onnx" in graphs
else "decoder.onnx"
)
self._kv_session = decoder_name == "decoder-kv.onnx"
self._decoder = ort.InferenceSession(
graphs[decoder_name], options, providers=_providers()
)
self._pasts = {
meta.name: _zero_past(meta)
for meta in self._decoder.get_inputs()
if meta.name.startswith("past_")
}
self._output_names = [o.name for o in self._decoder.get_outputs()]

@classmethod
def load(cls, path_or_id: Path | str, index_url: str | None = None) -> "Model":
"""Accepts a zip path OR a model id from models.yaml (dynamic
fetch: download -> verify -> cache)."""
candidate = str(path_or_id)
if candidate.endswith(".zip") or Path(candidate).exists():
return cls(candidate)
from interscript_ml.registry import resolve

return cls(resolve(candidate, index_url))

@property
def id(self) -> str:
return self.manifest.id

def translate(self, text: str, max_len: int = 256) -> str:
token_ids = self.generate(text, max_len=max_len)
return decode(token_ids)

def generate(self, text: str, max_len: int = 256) -> list[int]:
ids = np.array([encode(text)], dtype=np.int64)
if ids.shape[1] == 1: # only the trailing EOS: empty input
return []
hidden = self._encoder.run(None, {"input_ids": ids})[0]
if self._kv_session:
return self._greedy_kv(hidden, max_len)
return self._greedy_plain(hidden, max_len)

def _greedy_kv(self, hidden, max_len: int) -> list[int]:
pasts = dict(self._pasts)
current = np.array([[PAD_ID]], dtype=np.int64)
generated: list[int] = []
for _ in range(max_len):
outputs = self._decoder.run(
None,
{"input_ids": current, "encoder_hidden_states": hidden, **pasts},
)
results = dict(zip(self._output_names, outputs, strict=True))
token = int(np.argmax(results["logits"][0, -1]))
if token == EOS_ID:
break
generated.append(token)
pasts = {
name: results[name.replace("past_", "present_", 1)]
for name in pasts
}
current = np.array([[token]], dtype=np.int64)
return generated

def _greedy_plain(self, hidden, max_len: int) -> list[int]:
decoder_ids = np.array([[PAD_ID]], dtype=np.int64)
generated: list[int] = []
for _ in range(max_len):
logits = self._decoder.run(
None,
{"input_ids": decoder_ids, "encoder_hidden_states": hidden},
)[0]
token = int(np.argmax(logits[0, -1]))
if token == EOS_ID:
break
generated.append(token)
decoder_ids = np.concatenate(
[decoder_ids, np.array([[token]], dtype=np.int64)], axis=1
)
return generated


def _providers() -> list[str]:
import onnxruntime as ort

available = ort.get_available_providers()
preferred = [p for p in ("CPUExecutionProvider",) if p in available]
return preferred or available


def _zero_past(meta) -> object:
shape = meta.shape # [batch, heads, past_seq, d_kv], dynamic dims are str
heads = shape[1] if isinstance(shape[1], int) else 4
d_kv = shape[3] if isinstance(shape[3], int) else 8
dtype = np.float16 if meta.type == "tensor(float16)" else np.float32
return np.zeros((1, heads, 0, d_kv), dtype=dtype)
Loading
Loading