interscript-ml 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interscript_ml/__init__.py +33 -0
- interscript_ml/loader.py +68 -0
- interscript_ml/model.py +211 -0
- interscript_ml/registry.py +159 -0
- interscript_ml/tokens.py +30 -0
- interscript_ml-0.1.0.dist-info/METADATA +55 -0
- interscript_ml-0.1.0.dist-info/RECORD +9 -0
- interscript_ml-0.1.0.dist-info/WHEEL +5 -0
- interscript_ml-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
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.registry import RegistryError, resolve
|
|
19
|
+
from interscript_ml.tokens import BYTE_OFFSET, EOS_ID, PAD_ID, UNK_ID, decode, encode
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BYTE_OFFSET",
|
|
23
|
+
"EOS_ID",
|
|
24
|
+
"Manifest",
|
|
25
|
+
"Model",
|
|
26
|
+
"ModelFormatError",
|
|
27
|
+
"PAD_ID",
|
|
28
|
+
"RegistryError",
|
|
29
|
+
"UNK_ID",
|
|
30
|
+
"decode",
|
|
31
|
+
"encode",
|
|
32
|
+
"resolve",
|
|
33
|
+
]
|
interscript_ml/loader.py
ADDED
|
@@ -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
|
interscript_ml/model.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
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.loader import load_manifest, verify_and_read
|
|
10
|
+
from interscript_ml.tokens import EOS_ID, PAD_ID, decode, encode
|
|
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_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))
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def id(self) -> str:
|
|
60
|
+
return self.manifest.id
|
|
61
|
+
|
|
62
|
+
def translate(self, text: str, max_len: int = 256, num_beams: int = 1) -> str:
|
|
63
|
+
token_ids = self.generate(text, max_len=max_len, num_beams=num_beams)
|
|
64
|
+
return decode(token_ids)
|
|
65
|
+
|
|
66
|
+
def generate(self, text: str, max_len: int = 256, num_beams: int = 1) -> list[int]:
|
|
67
|
+
ids = np.array([encode(text)], dtype=np.int64)
|
|
68
|
+
if ids.shape[1] == 1: # only the trailing EOS: empty input
|
|
69
|
+
return []
|
|
70
|
+
hidden = self._encoder.run(None, {"input_ids": ids})[0]
|
|
71
|
+
if self._kv_session:
|
|
72
|
+
if num_beams > 1:
|
|
73
|
+
return self._beam_kv(hidden, max_len, num_beams)
|
|
74
|
+
return self._greedy_kv(hidden, max_len)
|
|
75
|
+
return self._greedy_plain(hidden, max_len)
|
|
76
|
+
|
|
77
|
+
def _beam_kv(self, hidden, max_len: int, num_beams: int) -> list[int]:
|
|
78
|
+
"""Batched beam search over the KV graph: the export's batch axis
|
|
79
|
+
carries the beams; per-step presents are gathered on beam reorder.
|
|
80
|
+
Canonical semantics: EOS hypotheses are recorded but never shrink
|
|
81
|
+
the live set (candidates come from a 2K window); the search runs
|
|
82
|
+
to max_len or exhaustion, and the winner is picked by raw
|
|
83
|
+
cumulative logprob — length normalization measurably rewards
|
|
84
|
+
long garbage on low-confidence byte models."""
|
|
85
|
+
beams = num_beams
|
|
86
|
+
enc = np.repeat(hidden, beams, axis=0)
|
|
87
|
+
pasts = {
|
|
88
|
+
name: np.repeat(zero, beams, axis=0)
|
|
89
|
+
for name, zero in self._pasts.items()
|
|
90
|
+
}
|
|
91
|
+
current = np.full((beams, 1), PAD_ID, dtype=np.int64)
|
|
92
|
+
scores = np.full((beams,), -np.inf, dtype=np.float32)
|
|
93
|
+
scores[0] = 0.0 # only beam 0 is live at step 0
|
|
94
|
+
sequences: list[list[int]] = [[] for _ in range(beams)]
|
|
95
|
+
finished: list[tuple[float, list[int]]] = []
|
|
96
|
+
|
|
97
|
+
for _ in range(max_len):
|
|
98
|
+
outputs = self._decoder.run(
|
|
99
|
+
None,
|
|
100
|
+
{"input_ids": current, "encoder_hidden_states": enc, **pasts},
|
|
101
|
+
)
|
|
102
|
+
results = dict(zip(self._output_names, outputs, strict=True))
|
|
103
|
+
logits = results["logits"][:, -1, :].astype(np.float32)
|
|
104
|
+
logprobs = logits - np.log(
|
|
105
|
+
np.exp(logits - logits.max(axis=1, keepdims=True)).sum(
|
|
106
|
+
axis=1, keepdims=True
|
|
107
|
+
)
|
|
108
|
+
) # stable log-softmax
|
|
109
|
+
cand = np.where(
|
|
110
|
+
(scores > -np.inf)[:, None],
|
|
111
|
+
scores[:, None] + logprobs,
|
|
112
|
+
-np.inf,
|
|
113
|
+
)
|
|
114
|
+
flat = cand.reshape(-1)
|
|
115
|
+
window = min(2 * beams, flat.size)
|
|
116
|
+
order = np.argpartition(flat, -window)[-window:]
|
|
117
|
+
order = order[np.argsort(-flat[order])]
|
|
118
|
+
new_pasts = {
|
|
119
|
+
name: results[name.replace("past_", "present_", 1)] for name in pasts
|
|
120
|
+
}
|
|
121
|
+
next_pasts: dict[str, np.ndarray] = {}
|
|
122
|
+
next_sequences: list[list[int]] = []
|
|
123
|
+
next_scores = np.full((beams,), -np.inf, dtype=np.float32)
|
|
124
|
+
next_current = np.full((beams, 1), PAD_ID, dtype=np.int64)
|
|
125
|
+
slot = 0
|
|
126
|
+
for idx in order:
|
|
127
|
+
src = int(idx // cand.shape[1])
|
|
128
|
+
token = int(idx % cand.shape[1])
|
|
129
|
+
score = float(flat[idx])
|
|
130
|
+
if token == EOS_ID:
|
|
131
|
+
finished.append((score, sequences[src]))
|
|
132
|
+
continue
|
|
133
|
+
if slot >= beams:
|
|
134
|
+
continue
|
|
135
|
+
next_scores[slot] = score
|
|
136
|
+
next_sequences.append(sequences[src] + [token])
|
|
137
|
+
next_current[slot, 0] = token
|
|
138
|
+
for name, tensor in new_pasts.items():
|
|
139
|
+
holder = next_pasts.setdefault(
|
|
140
|
+
name, np.empty((beams,) + tensor.shape[1:], tensor.dtype)
|
|
141
|
+
)
|
|
142
|
+
holder[slot : slot + 1] = tensor[src : src + 1]
|
|
143
|
+
slot += 1
|
|
144
|
+
if slot == 0:
|
|
145
|
+
break
|
|
146
|
+
pasts = next_pasts
|
|
147
|
+
sequences = next_sequences + [
|
|
148
|
+
[] for _ in range(beams - len(next_sequences))
|
|
149
|
+
]
|
|
150
|
+
scores = next_scores
|
|
151
|
+
current = next_current
|
|
152
|
+
|
|
153
|
+
pool = finished + [
|
|
154
|
+
(float(scores[i]), sequences[i]) for i in range(beams) if scores[i] > -np.inf
|
|
155
|
+
]
|
|
156
|
+
best = max(pool, key=lambda s: s[0])
|
|
157
|
+
return best[1]
|
|
158
|
+
|
|
159
|
+
def _greedy_kv(self, hidden, max_len: int) -> list[int]:
|
|
160
|
+
pasts = dict(self._pasts)
|
|
161
|
+
current = np.array([[PAD_ID]], dtype=np.int64)
|
|
162
|
+
generated: list[int] = []
|
|
163
|
+
for _ in range(max_len):
|
|
164
|
+
outputs = self._decoder.run(
|
|
165
|
+
None,
|
|
166
|
+
{"input_ids": current, "encoder_hidden_states": hidden, **pasts},
|
|
167
|
+
)
|
|
168
|
+
results = dict(zip(self._output_names, outputs, strict=True))
|
|
169
|
+
token = int(np.argmax(results["logits"][0, -1]))
|
|
170
|
+
if token == EOS_ID:
|
|
171
|
+
break
|
|
172
|
+
generated.append(token)
|
|
173
|
+
pasts = {
|
|
174
|
+
name: results[name.replace("past_", "present_", 1)]
|
|
175
|
+
for name in pasts
|
|
176
|
+
}
|
|
177
|
+
current = np.array([[token]], dtype=np.int64)
|
|
178
|
+
return generated
|
|
179
|
+
|
|
180
|
+
def _greedy_plain(self, hidden, max_len: int) -> list[int]:
|
|
181
|
+
decoder_ids = np.array([[PAD_ID]], dtype=np.int64)
|
|
182
|
+
generated: list[int] = []
|
|
183
|
+
for _ in range(max_len):
|
|
184
|
+
logits = self._decoder.run(
|
|
185
|
+
None,
|
|
186
|
+
{"input_ids": decoder_ids, "encoder_hidden_states": hidden},
|
|
187
|
+
)[0]
|
|
188
|
+
token = int(np.argmax(logits[0, -1]))
|
|
189
|
+
if token == EOS_ID:
|
|
190
|
+
break
|
|
191
|
+
generated.append(token)
|
|
192
|
+
decoder_ids = np.concatenate(
|
|
193
|
+
[decoder_ids, np.array([[token]], dtype=np.int64)], axis=1
|
|
194
|
+
)
|
|
195
|
+
return generated
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _providers() -> list[str]:
|
|
199
|
+
import onnxruntime as ort
|
|
200
|
+
|
|
201
|
+
available = ort.get_available_providers()
|
|
202
|
+
preferred = [p for p in ("CPUExecutionProvider",) if p in available]
|
|
203
|
+
return preferred or available
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _zero_past(meta) -> object:
|
|
207
|
+
shape = meta.shape # [batch, heads, past_seq, d_kv], dynamic dims are str
|
|
208
|
+
heads = shape[1] if isinstance(shape[1], int) else 4
|
|
209
|
+
d_kv = shape[3] if isinstance(shape[3], int) else 8
|
|
210
|
+
dtype = np.float16 if meta.type == "tensor(float16)" else np.float32
|
|
211
|
+
return np.zeros((1, heads, 0, d_kv), dtype=dtype)
|
|
@@ -0,0 +1,159 @@
|
|
|
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/interscript-ml/main/models.yaml"
|
|
23
|
+
)
|
|
24
|
+
ENV_INDEX = "SECRYST_INDEX"
|
|
25
|
+
ENV_CACHE = "SECRYST_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 Part:
|
|
34
|
+
url: str
|
|
35
|
+
sha256: str
|
|
36
|
+
size: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class IndexEntry:
|
|
41
|
+
id: str
|
|
42
|
+
filename: str
|
|
43
|
+
url: str
|
|
44
|
+
sha256: str
|
|
45
|
+
size: int
|
|
46
|
+
precision: str
|
|
47
|
+
task: str
|
|
48
|
+
parts: tuple[Part, ...] = ()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cache_dir() -> Path:
|
|
52
|
+
if os.environ.get(ENV_CACHE):
|
|
53
|
+
return Path(os.environ[ENV_CACHE])
|
|
54
|
+
return Path.home() / ".cache" / "secryst"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_index(index_url: str | None = None) -> dict[str, IndexEntry]:
|
|
58
|
+
source = index_url or os.environ.get(ENV_INDEX) or DEFAULT_INDEX_URL
|
|
59
|
+
if source.startswith(("http://", "https://")):
|
|
60
|
+
with urllib.request.urlopen(source) as response:
|
|
61
|
+
text = response.read().decode("utf-8")
|
|
62
|
+
else:
|
|
63
|
+
text = Path(source).read_text(encoding="utf-8")
|
|
64
|
+
raw = yaml.safe_load(text)
|
|
65
|
+
if not isinstance(raw, dict) or raw.get("version") != 1:
|
|
66
|
+
raise RegistryError("index must be a mapping with version: 1")
|
|
67
|
+
entries: dict[str, IndexEntry] = {}
|
|
68
|
+
for model_id, spec in raw.get("models", {}).items():
|
|
69
|
+
parts = tuple(
|
|
70
|
+
Part(url=part["url"], sha256=part["sha256"], size=int(part.get("size", 0)))
|
|
71
|
+
for part in spec.get("parts", [])
|
|
72
|
+
)
|
|
73
|
+
entries[model_id] = IndexEntry(
|
|
74
|
+
id=model_id,
|
|
75
|
+
filename=spec["filename"],
|
|
76
|
+
url=spec.get("url", ""),
|
|
77
|
+
sha256=spec["sha256"],
|
|
78
|
+
size=int(spec.get("size", 0)),
|
|
79
|
+
precision=spec.get("precision", "fp32"),
|
|
80
|
+
task=spec.get("task", ""),
|
|
81
|
+
parts=parts,
|
|
82
|
+
)
|
|
83
|
+
return entries
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sha256_file(path: Path) -> str:
|
|
87
|
+
digest = hashlib.sha256()
|
|
88
|
+
with path.open("rb") as fh:
|
|
89
|
+
while chunk := fh.read(1024 * 1024):
|
|
90
|
+
digest.update(chunk)
|
|
91
|
+
return digest.hexdigest()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _open_channel(url: str):
|
|
95
|
+
if url.startswith("file://"):
|
|
96
|
+
return open(urlparse(url).path, "rb")
|
|
97
|
+
return urllib.request.urlopen(url)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _download_parts(entry: IndexEntry, downloaded: Path) -> None:
|
|
101
|
+
"""Stream parts into `downloaded` in index order, verifying each part's
|
|
102
|
+
sha256 as it lands. Used when the artifact exceeds GitHub's 2 GiB
|
|
103
|
+
per-asset cap; the assembled file is checked against entry.sha256 by
|
|
104
|
+
the caller, so the cache contract is identical to single-file models."""
|
|
105
|
+
with downloaded.open("ab") as out:
|
|
106
|
+
for index, part in enumerate(entry.parts):
|
|
107
|
+
digest = hashlib.sha256()
|
|
108
|
+
with _open_channel(part.url) as remote:
|
|
109
|
+
while chunk := remote.read(1024 * 1024):
|
|
110
|
+
out.write(chunk)
|
|
111
|
+
digest.update(chunk)
|
|
112
|
+
actual = digest.hexdigest()
|
|
113
|
+
if actual != part.sha256:
|
|
114
|
+
raise RegistryError(
|
|
115
|
+
f"part {index} of {entry.filename} sha256 mismatch: "
|
|
116
|
+
f"got {actual}, index says {part.sha256}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve(model_id: str, index_url: str | None = None) -> Path:
|
|
121
|
+
"""Return a verified local zip path for `model_id`, downloading and
|
|
122
|
+
installing into the cache when needed. Never returns an unverified
|
|
123
|
+
file: cache hits are re-verified against the index sha256."""
|
|
124
|
+
entries = load_index(index_url)
|
|
125
|
+
if model_id not in entries:
|
|
126
|
+
raise RegistryError(
|
|
127
|
+
f"unknown model id {model_id!r} (known: {sorted(entries)})"
|
|
128
|
+
)
|
|
129
|
+
entry = entries[model_id]
|
|
130
|
+
target = cache_dir() / "models" / model_id / entry.filename
|
|
131
|
+
if target.is_file() and _sha256_file(target) == entry.sha256:
|
|
132
|
+
return target
|
|
133
|
+
|
|
134
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
fd, tmp_name = tempfile.mkstemp(dir=target.parent, suffix=".part")
|
|
136
|
+
os.close(fd)
|
|
137
|
+
downloaded = Path(tmp_name)
|
|
138
|
+
if entry.parts:
|
|
139
|
+
_download_parts(entry, downloaded)
|
|
140
|
+
elif entry.url.startswith("file://"):
|
|
141
|
+
source = Path(urlparse(entry.url).path)
|
|
142
|
+
if not source.is_file():
|
|
143
|
+
raise RegistryError(f"channel file missing: {source}")
|
|
144
|
+
shutil.copyfile(source, downloaded) # file:// is a mirror, not a move
|
|
145
|
+
else:
|
|
146
|
+
urllib.request.urlretrieve(entry.url, downloaded)
|
|
147
|
+
try:
|
|
148
|
+
actual = _sha256_file(downloaded)
|
|
149
|
+
if actual != entry.sha256:
|
|
150
|
+
raise RegistryError(
|
|
151
|
+
f"downloaded {entry.filename} sha256 mismatch: got {actual}, "
|
|
152
|
+
f"index says {entry.sha256}"
|
|
153
|
+
)
|
|
154
|
+
if downloaded != target:
|
|
155
|
+
os.replace(downloaded, target)
|
|
156
|
+
finally:
|
|
157
|
+
if downloaded != target and downloaded.exists():
|
|
158
|
+
downloaded.unlink()
|
|
159
|
+
return target
|
interscript_ml/tokens.py
ADDED
|
@@ -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,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: interscript-ml
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python runtime for Interscript Model Format (IMF v1) — the phonological layer of Interscript
|
|
5
|
+
Author: Interscript Project
|
|
6
|
+
License: BSD-3-Clause
|
|
7
|
+
Keywords: transliteration,diacritization,g2p,onnx,byt5
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: numpy>=1.26
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Requires-Dist: onnxruntime>=1.17
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: onnx>=1.16; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# interscript-ml (Python runtime)
|
|
18
|
+
|
|
19
|
+
The reference Python runtime for **IMF v1** model zips — the phonological
|
|
20
|
+
layer of Interscript. The Ruby (secryst gem) and TypeScript
|
|
21
|
+
(@interscript/ml) runtimes are diffed against this one on shared golden
|
|
22
|
+
sets.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from interscript_ml import Model
|
|
26
|
+
|
|
27
|
+
model = Model.load("khm-latn-1.0") # id: index resolve -> download
|
|
28
|
+
# -> sha256-verify -> cache -> load
|
|
29
|
+
model.translate("ភាសា") # -> "pheasaea"
|
|
30
|
+
model.id # "khm-latn-1.0"
|
|
31
|
+
|
|
32
|
+
model = Model.load("khm-latn-1.0.zip") # or: a local zip path directly
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- Byte-level only: the canonical ByT5 table (byte `b` → id `b+3`,
|
|
36
|
+
trailing EOS) — no vocab files, no per-model tokenization code.
|
|
37
|
+
- Greedy KV-cache decode when the zip ships `decoder-kv.onnx`
|
|
38
|
+
(default), plain full-recompute fallback otherwise.
|
|
39
|
+
- Every `.onnx` member is sha256-verified against `metadata.yaml`
|
|
40
|
+
before the session is created; corrupt downloads fail loudly.
|
|
41
|
+
- Dynamic fetch per the `models.yaml` contract (shared with the Ruby and
|
|
42
|
+
TypeScript runtimes): resolve id -> channel URL, download to temp,
|
|
43
|
+
verify whole-file sha256 against the index, atomically install into
|
|
44
|
+
`~/.cache/interscript/models/<id>/`. Overrides:
|
|
45
|
+
`SECRYST_INDEX` (URL or path), `SECRYST_CACHE`.
|
|
46
|
+
|
|
47
|
+
Install: `pip install ./runtime` (from the interscript-ml checkout) or
|
|
48
|
+
`pip install -e "./runtime[dev]"` for development.
|
|
49
|
+
|
|
50
|
+
Tests: `python -m pytest runtime/tests` — tiny-graph zips, no torch
|
|
51
|
+
needed. The end-to-end golden test runs when `SECRYST_E2E_ZIP`
|
|
52
|
+
points at a real zip (e.g. `models/khm-latn/khm-latn-1.0-fp32.zip`)
|
|
53
|
+
and asserts byte-identical outputs against `golden/khm-latn-100.jsonl`.
|
|
54
|
+
|
|
55
|
+
License: BSD-3-Clause.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interscript_ml/__init__.py,sha256=qnoVrOcnMimA93q9PyGxtMj8A9SWjjxJjif-IwB-e7g,955
|
|
2
|
+
interscript_ml/loader.py,sha256=lau1Ed9EXmhRdGiwKt_BuYApZrfAO1bXvo6rUZ9_NgU,2321
|
|
3
|
+
interscript_ml/model.py,sha256=ZmPtzuhezZ91ecw0bgb9wPhl122-HS6GMjZBUO4Uo7o,8432
|
|
4
|
+
interscript_ml/registry.py,sha256=qmy7NTgXXHZaVzCt6h4VC1SGImIbn57KQLPFwbVzYh4,5303
|
|
5
|
+
interscript_ml/tokens.py,sha256=UV59DlvAte1VDZRB_rW7hQ2dOCgIiMRWkvcoJ3N9nTk,884
|
|
6
|
+
interscript_ml-0.1.0.dist-info/METADATA,sha256=7zBvwM1uIaUtO9dF3Dt_PC_YVCGj39YKPHaJ8POKMEU,2291
|
|
7
|
+
interscript_ml-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
interscript_ml-0.1.0.dist-info/top_level.txt,sha256=YBwZ6Bpz1TTlnsES0VcaIGF0aG4azG1RTeY_kmRFQkI,15
|
|
9
|
+
interscript_ml-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
interscript_ml
|