Skip to content

Commit 662e850

Browse files
Ronald Tseronaldtse
authored andcommitted
feat(runtime): models.yaml index + dynamic fetch (WO08 core)
The release/fetch contract shared by all three runtimes, documented in models.yaml: resolve id -> channel URL -> download to temp -> verify whole-file sha256 against the index -> atomic install into ~/.cache/interscript/models/<id>/. Cache hits are re-verified; file:// channels copy (mirrors, not moves). Model.load now accepts an id or a zip path. First entry: khm-latn-1.0 fp32 (gated: 0.0pp parity on 895 samples) pointing at the pending khm-latn-1.0 GitHub release. Verified end-to-end against the real gated zip through a local channel: Model.load('khm-latn-1.0') -> 'rok' / 'pheasaea'.
1 parent 42f3afb commit 662e850

7 files changed

Lines changed: 323 additions & 5 deletions

File tree

models.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# interscript-ml model index — the stable URL every runtime resolves
2+
# model ids against (Ruby / TypeScript / Python implement the same
3+
# algorithm; this file is the contract).
4+
#
5+
# Resolution algorithm (identical in all runtimes):
6+
# 1. resolve `id` in models.models
7+
# 2. prefer an installed cache copy at <cache_dir>/<id>/<filename>
8+
# whose whole-file sha256 matches `sha256`
9+
# 3. else download `url` to a temp file in the same directory,
10+
# verify sha256, atomically rename into place
11+
# 4. load the zip (IMF v1: member sha256 verification on load)
12+
#
13+
# Overrides: INTERSCRIPT_ML_INDEX (URL or path to an index like this one),
14+
# INTERSCRIPT_ML_CACHE (cache directory; default ~/.cache/interscript).
15+
#
16+
# Adding a model: it must have passed the WO03 gate (strict validation,
17+
# parity written into the zip) before an entry ships here.
18+
version: 1
19+
models:
20+
khm-latn-1.0:
21+
task: translit
22+
scripts: [Khmr, Latn]
23+
precision: fp32
24+
filename: khm-latn-1.0-fp32.zip
25+
url: https://github.com/interscript/ml-models/releases/download/khm-latn-1.0/khm-latn-1.0-fp32.zip
26+
sha256: 55993d473a2ed9489058779cad7e115db05e4020085b71616468cfae4f2f65cb
27+
size: 1418009977
28+
metrics:
29+
- {name: cer, value: 27.42, source: secryst/docs/RESULTS.md#khmer-transliteration-2026-08-14}
30+
- {name: em, value: 59.66, source: secryst/docs/RESULTS.md#khmer-transliteration-2026-08-14}
31+
parity: {samples: 895, cer_delta: 0.0}
32+
license: BSD-3-Clause

runtime/README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ sets.
88
```python
99
from interscript_ml import Model
1010

11-
model = Model.load("khm-latn-1.0.zip") # sha256-verified on load
12-
model.translate("ភាសា") # -> "pheasaea"
13-
model.id # "khm-latn-1.0"
11+
model = Model.load("khm-latn-1.0") # id: index resolve -> download
12+
# -> sha256-verify -> cache -> load
13+
model.translate("ភាសា") # -> "pheasaea"
14+
model.id # "khm-latn-1.0"
15+
16+
model = Model.load("khm-latn-1.0.zip") # or: a local zip path directly
1417
```
1518

1619
- Byte-level only: the canonical ByT5 table (byte `b` → id `b+3`,
@@ -19,6 +22,11 @@ model.id # "khm-latn-1.0"
1922
(default), plain full-recompute fallback otherwise.
2023
- Every `.onnx` member is sha256-verified against `metadata.yaml`
2124
before the session is created; corrupt downloads fail loudly.
25+
- Dynamic fetch per the `models.yaml` contract (shared with the Ruby and
26+
TypeScript runtimes): resolve id -> channel URL, download to temp,
27+
verify whole-file sha256 against the index, atomically install into
28+
`~/.cache/interscript/models/<id>/`. Overrides:
29+
`INTERSCRIPT_ML_INDEX` (URL or path), `INTERSCRIPT_ML_CACHE`.
2230

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

runtime/src/interscript_ml/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from interscript_ml.loader import Manifest, ModelFormatError
1717
from interscript_ml.model import Model
18+
from interscript_ml.registry import RegistryError, resolve
1819
from interscript_ml.tokens import BYTE_OFFSET, EOS_ID, PAD_ID, UNK_ID, decode, encode
1920

2021
__all__ = [
@@ -24,7 +25,9 @@
2425
"Model",
2526
"ModelFormatError",
2627
"PAD_ID",
28+
"RegistryError",
2729
"UNK_ID",
2830
"decode",
2931
"encode",
32+
"resolve",
3033
]

runtime/src/interscript_ml/model.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,15 @@ def __init__(self, zip_path: Path | str):
4545
self._output_names = [o.name for o in self._decoder.get_outputs()]
4646

4747
@classmethod
48-
def load(cls, path: Path | str) -> "Model":
49-
return cls(path)
48+
def load(cls, path_or_id: Path | str, index_url: str | None = None) -> "Model":
49+
"""Accepts a zip path OR a model id from models.yaml (dynamic
50+
fetch: download -> verify -> cache)."""
51+
candidate = str(path_or_id)
52+
if candidate.endswith(".zip") or Path(candidate).exists():
53+
return cls(candidate)
54+
from interscript_ml.registry import resolve
55+
56+
return cls(resolve(candidate, index_url))
5057

5158
@property
5259
def id(self) -> str:
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Model index resolution + cached downloads (the dynamic-fetch layer).
2+
3+
Implements the models.yaml contract shared by the Ruby and TypeScript
4+
runtimes: resolve an id, reuse a verified cache copy, or download +
5+
sha256-verify + atomically install into the cache.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import hashlib
11+
import os
12+
import shutil
13+
import tempfile
14+
import urllib.request
15+
from dataclasses import dataclass
16+
from pathlib import Path
17+
from urllib.parse import urlparse
18+
19+
import yaml
20+
21+
DEFAULT_INDEX_URL = (
22+
"https://raw.githubusercontent.com/interscript/ml-models/main/models.yaml"
23+
)
24+
ENV_INDEX = "INTERSCRIPT_ML_INDEX"
25+
ENV_CACHE = "INTERSCRIPT_ML_CACHE"
26+
27+
28+
class RegistryError(ValueError):
29+
"""The index cannot be fetched/parsed, or the id is unknown."""
30+
31+
32+
@dataclass(frozen=True)
33+
class IndexEntry:
34+
id: str
35+
filename: str
36+
url: str
37+
sha256: str
38+
size: int
39+
precision: str
40+
task: str
41+
42+
43+
def cache_dir() -> Path:
44+
if os.environ.get(ENV_CACHE):
45+
return Path(os.environ[ENV_CACHE])
46+
return Path.home() / ".cache" / "interscript"
47+
48+
49+
def load_index(index_url: str | None = None) -> dict[str, IndexEntry]:
50+
source = index_url or os.environ.get(ENV_INDEX) or DEFAULT_INDEX_URL
51+
if source.startswith(("http://", "https://")):
52+
with urllib.request.urlopen(source) as response:
53+
text = response.read().decode("utf-8")
54+
else:
55+
text = Path(source).read_text(encoding="utf-8")
56+
raw = yaml.safe_load(text)
57+
if not isinstance(raw, dict) or raw.get("version") != 1:
58+
raise RegistryError("index must be a mapping with version: 1")
59+
entries: dict[str, IndexEntry] = {}
60+
for model_id, spec in raw.get("models", {}).items():
61+
entries[model_id] = IndexEntry(
62+
id=model_id,
63+
filename=spec["filename"],
64+
url=spec["url"],
65+
sha256=spec["sha256"],
66+
size=int(spec.get("size", 0)),
67+
precision=spec.get("precision", "fp32"),
68+
task=spec.get("task", ""),
69+
)
70+
return entries
71+
72+
73+
def _sha256_file(path: Path) -> str:
74+
digest = hashlib.sha256()
75+
with path.open("rb") as fh:
76+
while chunk := fh.read(1024 * 1024):
77+
digest.update(chunk)
78+
return digest.hexdigest()
79+
80+
81+
def resolve(model_id: str, index_url: str | None = None) -> Path:
82+
"""Return a verified local zip path for `model_id`, downloading and
83+
installing into the cache when needed. Never returns an unverified
84+
file: cache hits are re-verified against the index sha256."""
85+
entries = load_index(index_url)
86+
if model_id not in entries:
87+
raise RegistryError(
88+
f"unknown model id {model_id!r} (known: {sorted(entries)})"
89+
)
90+
entry = entries[model_id]
91+
target = cache_dir() / "models" / model_id / entry.filename
92+
if target.is_file() and _sha256_file(target) == entry.sha256:
93+
return target
94+
95+
target.parent.mkdir(parents=True, exist_ok=True)
96+
fd, tmp_name = tempfile.mkstemp(dir=target.parent, suffix=".part")
97+
os.close(fd)
98+
downloaded = Path(tmp_name)
99+
if entry.url.startswith("file://"):
100+
source = Path(urlparse(entry.url).path)
101+
if not source.is_file():
102+
raise RegistryError(f"channel file missing: {source}")
103+
shutil.copyfile(source, downloaded) # file:// is a mirror, not a move
104+
else:
105+
urllib.request.urlretrieve(entry.url, downloaded)
106+
try:
107+
actual = _sha256_file(downloaded)
108+
if actual != entry.sha256:
109+
raise RegistryError(
110+
f"downloaded {entry.filename} sha256 mismatch: got {actual}, "
111+
f"index says {entry.sha256}"
112+
)
113+
if downloaded != target:
114+
os.replace(downloaded, target)
115+
finally:
116+
if downloaded != target and downloaded.exists():
117+
downloaded.unlink()
118+
return target

runtime/tests/test_registry.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Tests for the dynamic-fetch layer (models.yaml resolution + cache)."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import zipfile
7+
from pathlib import Path
8+
9+
import pytest
10+
import yaml
11+
12+
from interscript_ml.registry import RegistryError, resolve
13+
from tests_helpers import build_tiny_zip
14+
15+
import os # noqa: E402
16+
17+
18+
def _index_file(tmp_path: Path, zip_path: Path, sha256: str | None = None) -> Path:
19+
index = {
20+
"version": 1,
21+
"models": {
22+
"tiny-1.0": {
23+
"task": "translit",
24+
"precision": "fp32",
25+
"filename": zip_path.name,
26+
"url": f"file://{zip_path}",
27+
"sha256": sha256 or hashlib.sha256(zip_path.read_bytes()).hexdigest(),
28+
"size": zip_path.stat().st_size,
29+
}
30+
},
31+
}
32+
path = tmp_path / "models.yaml"
33+
path.write_text(yaml.safe_dump(index), encoding="utf-8")
34+
return path
35+
36+
37+
def test_resolve_downloads_verifies_and_caches(tmp_path: Path) -> None:
38+
zip_path = build_tiny_zip(tmp_path / "channel" / "tiny.zip")
39+
index = _index_file(tmp_path, zip_path)
40+
cache = tmp_path / "cache"
41+
os.environ["INTERSCRIPT_ML_CACHE"] = str(cache)
42+
try:
43+
local = resolve("tiny-1.0", index_url=str(index))
44+
assert local == cache / "models" / "tiny-1.0" / "tiny.zip"
45+
assert local.is_file()
46+
# second resolve is a verified cache hit (channel dir removed)
47+
zip_path.unlink()
48+
assert resolve("tiny-1.0", index_url=str(index)) == local
49+
finally:
50+
os.environ.pop("INTERSCRIPT_ML_CACHE", None)
51+
52+
53+
def test_resolve_rejects_bad_download(tmp_path: Path) -> None:
54+
zip_path = build_tiny_zip(tmp_path / "channel" / "tiny.zip")
55+
index = _index_file(tmp_path, zip_path, sha256="0" * 64)
56+
os.environ["INTERSCRIPT_ML_CACHE"] = str(tmp_path / "cache")
57+
try:
58+
with pytest.raises(RegistryError, match="sha256 mismatch"):
59+
resolve("tiny-1.0", index_url=str(index))
60+
finally:
61+
os.environ.pop("INTERSCRIPT_ML_CACHE", None)
62+
63+
64+
def test_resolve_unknown_id(tmp_path: Path) -> None:
65+
index = tmp_path / "models.yaml"
66+
index.write_text(yaml.safe_dump({"version": 1, "models": {}}), encoding="utf-8")
67+
with pytest.raises(RegistryError, match="unknown model id"):
68+
resolve("nope-1.0", index_url=str(index))
69+
70+
71+
def test_model_load_by_id(tmp_path: Path) -> None:
72+
zip_path = build_tiny_zip(tmp_path / "channel" / "tiny.zip")
73+
index = _index_file(tmp_path, zip_path)
74+
os.environ["INTERSCRIPT_ML_CACHE"] = str(tmp_path / "cache")
75+
try:
76+
from interscript_ml import Model
77+
78+
model = Model.load("tiny-1.0", index_url=str(index))
79+
assert model.id == "tiny-1.0"
80+
assert isinstance(model.translate("he", max_len=4), str)
81+
finally:
82+
os.environ.pop("INTERSCRIPT_ML_CACHE", None)

runtime/tests/tests_helpers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Shared tiny-graph zip builder for runtime tests."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import zipfile
7+
from pathlib import Path
8+
9+
import numpy as np
10+
import yaml
11+
from onnx import TensorProto, helper, numpy_helper
12+
13+
MANIFEST = {
14+
"format": "imf-v1",
15+
"id": "tiny-1.0",
16+
"task": "translit",
17+
"source_script": "Latn",
18+
"target": "Latn",
19+
"tokenizer": "bytes",
20+
"opset": 14,
21+
"decoder": "plain",
22+
"precision": "fp32",
23+
"license": "BSD-3-Clause",
24+
"trained_from": "runtime test fixture",
25+
}
26+
27+
28+
def _add_graph(name: str, inputs: list[str], output: str) -> bytes:
29+
graph = helper.make_graph(
30+
nodes=[helper.make_node("Add", [inputs[0], "bias"], [output])],
31+
name=name,
32+
inputs=[
33+
helper.make_tensor_value_info(n, TensorProto.INT64, ["batch", "seq"])
34+
for n in inputs
35+
],
36+
outputs=[
37+
helper.make_tensor_value_info(output, TensorProto.INT64, ["batch", "seq"])
38+
],
39+
initializer=[numpy_helper.from_array(np.zeros(1, dtype=np.int64), "bias")],
40+
)
41+
model = helper.make_model(
42+
graph, opset_imports=[helper.make_opsetid("", 14)], ir_version=7
43+
)
44+
return model.SerializeToString()
45+
46+
47+
def build_tiny_zip(
48+
path: Path, tamper: bool = False, manifest: dict | None = None
49+
) -> Path:
50+
encoder = _add_graph("tiny-enc", ["input_ids"], "last_hidden_state")
51+
decoder = _add_graph(
52+
"tiny-dec", ["input_ids", "encoder_hidden_states"], "logits"
53+
)
54+
sha = {
55+
"encoder.onnx": hashlib.sha256(encoder).hexdigest(),
56+
"decoder.onnx": hashlib.sha256(decoder).hexdigest(),
57+
}
58+
if tamper:
59+
sha["encoder.onnx"] = "0" * 64
60+
meta = dict(manifest if manifest is not None else MANIFEST)
61+
meta["sha256"] = sha
62+
path.parent.mkdir(parents=True, exist_ok=True)
63+
with zipfile.ZipFile(path, "w") as zf:
64+
zf.writestr("metadata.yaml", yaml.safe_dump(meta))
65+
zf.writestr("encoder.onnx", encoder)
66+
zf.writestr("decoder.onnx", decoder)
67+
zf.writestr("README.md", "# tiny\n")
68+
return path

0 commit comments

Comments
 (0)