secryst 0.1.0__tar.gz
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.
- secryst-0.1.0/LICENSE +30 -0
- secryst-0.1.0/PKG-INFO +64 -0
- secryst-0.1.0/README.md +46 -0
- secryst-0.1.0/pyproject.toml +30 -0
- secryst-0.1.0/setup.cfg +4 -0
- secryst-0.1.0/src/secryst/__init__.py +33 -0
- secryst-0.1.0/src/secryst/loader.py +68 -0
- secryst-0.1.0/src/secryst/model.py +127 -0
- secryst-0.1.0/src/secryst/registry.py +159 -0
- secryst-0.1.0/src/secryst/tokens.py +30 -0
- secryst-0.1.0/src/secryst.egg-info/PKG-INFO +64 -0
- secryst-0.1.0/src/secryst.egg-info/SOURCES.txt +16 -0
- secryst-0.1.0/src/secryst.egg-info/dependency_links.txt +1 -0
- secryst-0.1.0/src/secryst.egg-info/requires.txt +7 -0
- secryst-0.1.0/src/secryst.egg-info/top_level.txt +1 -0
- secryst-0.1.0/tests/test_model.py +159 -0
- secryst-0.1.0/tests/test_registry.py +170 -0
- secryst-0.1.0/tests/tests_helpers.py +68 -0
secryst-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Ribose Inc.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
23
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
24
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
25
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
26
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
27
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
28
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
29
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
30
|
+
POSSIBILITY OF SUCH DAMAGE.
|
secryst-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: secryst
|
|
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
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=1.26
|
|
12
|
+
Requires-Dist: pyyaml>=6.0
|
|
13
|
+
Requires-Dist: onnxruntime>=1.17
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
16
|
+
Requires-Dist: onnx>=1.16; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# secryst — Python crystal
|
|
20
|
+
|
|
21
|
+
**Secryst** is coined from *scrying* + *crystal*: gazing into an opaque
|
|
22
|
+
script to reveal its hidden reading. This package is the Python crystal.
|
|
23
|
+
|
|
24
|
+
The Python crystal — reference implementation of **IMF v1** model zips
|
|
25
|
+
and the `models.yaml` index (the **interscript-ml** contract), the
|
|
26
|
+
phonological layer of Interscript. The Ruby (`secryst` gem) and
|
|
27
|
+
TypeScript (`npm i secryst`) crystals are diffed against this one on
|
|
28
|
+
shared golden sets. This crystal owns golden generation and numerical
|
|
29
|
+
adjudication for the family.
|
|
30
|
+
|
|
31
|
+
Home repo: https://github.com/secryst/secryst-py (this copy in
|
|
32
|
+
ml-models/runtime is the frozen origin; the package now lives there).
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from secryst import Model
|
|
36
|
+
|
|
37
|
+
model = Model.load("khm-latn-1.0") # id: index resolve -> download
|
|
38
|
+
# -> sha256-verify -> cache -> load
|
|
39
|
+
model.translate("ភាសា") # -> "pheasaea"
|
|
40
|
+
model.id # "khm-latn-1.0"
|
|
41
|
+
|
|
42
|
+
model = Model.load("khm-latn-1.0.zip") # or: a local zip path directly
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Byte-level only: the canonical ByT5 table (byte `b` → id `b+3`,
|
|
46
|
+
trailing EOS) — no vocab files, no per-model tokenization code.
|
|
47
|
+
- Greedy KV-cache decode when the zip ships `decoder-kv.onnx`
|
|
48
|
+
(default), plain full-recompute fallback otherwise.
|
|
49
|
+
- Every `.onnx` member is sha256-verified against `metadata.yaml`
|
|
50
|
+
before the session is created; corrupt downloads fail loudly.
|
|
51
|
+
- Dynamic fetch per the `models.yaml` contract (shared with the Ruby and
|
|
52
|
+
TypeScript runtimes): resolve id -> channel URL, download to temp,
|
|
53
|
+
verify whole-file sha256 against the index, atomically install into
|
|
54
|
+
`~/.cache/secryst/models/<id>/`. Overrides:
|
|
55
|
+
`SECRYST_INDEX` (URL or path), `SECRYST_CACHE`.
|
|
56
|
+
|
|
57
|
+
Install: `pip install secryst` (or `pip install -e ".[dev]"` from the repo).
|
|
58
|
+
|
|
59
|
+
Tests: `python -m pytest runtime/tests` — tiny-graph zips, no torch
|
|
60
|
+
needed. The end-to-end golden test runs when `SECRYST_E2E_ZIP`
|
|
61
|
+
points at a real zip (e.g. `models/khm-latn/khm-latn-1.0-fp32.zip`)
|
|
62
|
+
and asserts byte-identical outputs against `golden/khm-latn-100.jsonl`.
|
|
63
|
+
|
|
64
|
+
License: BSD-3-Clause.
|
secryst-0.1.0/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# secryst — Python crystal
|
|
2
|
+
|
|
3
|
+
**Secryst** is coined from *scrying* + *crystal*: gazing into an opaque
|
|
4
|
+
script to reveal its hidden reading. This package is the Python crystal.
|
|
5
|
+
|
|
6
|
+
The Python crystal — reference implementation of **IMF v1** model zips
|
|
7
|
+
and the `models.yaml` index (the **interscript-ml** contract), the
|
|
8
|
+
phonological layer of Interscript. The Ruby (`secryst` gem) and
|
|
9
|
+
TypeScript (`npm i secryst`) crystals are diffed against this one on
|
|
10
|
+
shared golden sets. This crystal owns golden generation and numerical
|
|
11
|
+
adjudication for the family.
|
|
12
|
+
|
|
13
|
+
Home repo: https://github.com/secryst/secryst-py (this copy in
|
|
14
|
+
ml-models/runtime is the frozen origin; the package now lives there).
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from secryst import Model
|
|
18
|
+
|
|
19
|
+
model = Model.load("khm-latn-1.0") # id: index resolve -> download
|
|
20
|
+
# -> sha256-verify -> cache -> load
|
|
21
|
+
model.translate("ភាសា") # -> "pheasaea"
|
|
22
|
+
model.id # "khm-latn-1.0"
|
|
23
|
+
|
|
24
|
+
model = Model.load("khm-latn-1.0.zip") # or: a local zip path directly
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- Byte-level only: the canonical ByT5 table (byte `b` → id `b+3`,
|
|
28
|
+
trailing EOS) — no vocab files, no per-model tokenization code.
|
|
29
|
+
- Greedy KV-cache decode when the zip ships `decoder-kv.onnx`
|
|
30
|
+
(default), plain full-recompute fallback otherwise.
|
|
31
|
+
- Every `.onnx` member is sha256-verified against `metadata.yaml`
|
|
32
|
+
before the session is created; corrupt downloads fail loudly.
|
|
33
|
+
- Dynamic fetch per the `models.yaml` contract (shared with the Ruby and
|
|
34
|
+
TypeScript runtimes): resolve id -> channel URL, download to temp,
|
|
35
|
+
verify whole-file sha256 against the index, atomically install into
|
|
36
|
+
`~/.cache/secryst/models/<id>/`. Overrides:
|
|
37
|
+
`SECRYST_INDEX` (URL or path), `SECRYST_CACHE`.
|
|
38
|
+
|
|
39
|
+
Install: `pip install secryst` (or `pip install -e ".[dev]"` from the repo).
|
|
40
|
+
|
|
41
|
+
Tests: `python -m pytest runtime/tests` — tiny-graph zips, no torch
|
|
42
|
+
needed. The end-to-end golden test runs when `SECRYST_E2E_ZIP`
|
|
43
|
+
points at a real zip (e.g. `models/khm-latn/khm-latn-1.0-fp32.zip`)
|
|
44
|
+
and asserts byte-identical outputs against `golden/khm-latn-100.jsonl`.
|
|
45
|
+
|
|
46
|
+
License: BSD-3-Clause.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "secryst"
|
|
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", "onnx>=1.16"]
|
|
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"
|
secryst-0.1.0/setup.cfg
ADDED
|
@@ -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 secryst 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 secryst.loader import Manifest, ModelFormatError
|
|
17
|
+
from secryst.model import Model
|
|
18
|
+
from secryst.registry import RegistryError, resolve
|
|
19
|
+
from secryst.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
|
+
]
|
|
@@ -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
|
|
@@ -0,0 +1,127 @@
|
|
|
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 secryst.tokens import EOS_ID, PAD_ID, decode, encode
|
|
10
|
+
from secryst.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_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 secryst.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) -> str:
|
|
63
|
+
token_ids = self.generate(text, max_len=max_len)
|
|
64
|
+
return decode(token_ids)
|
|
65
|
+
|
|
66
|
+
def generate(self, text: str, max_len: int = 256) -> 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
|
+
return self._greedy_kv(hidden, max_len)
|
|
73
|
+
return self._greedy_plain(hidden, max_len)
|
|
74
|
+
|
|
75
|
+
def _greedy_kv(self, hidden, max_len: int) -> list[int]:
|
|
76
|
+
pasts = dict(self._pasts)
|
|
77
|
+
current = np.array([[PAD_ID]], dtype=np.int64)
|
|
78
|
+
generated: list[int] = []
|
|
79
|
+
for _ in range(max_len):
|
|
80
|
+
outputs = self._decoder.run(
|
|
81
|
+
None,
|
|
82
|
+
{"input_ids": current, "encoder_hidden_states": hidden, **pasts},
|
|
83
|
+
)
|
|
84
|
+
results = dict(zip(self._output_names, outputs, strict=True))
|
|
85
|
+
token = int(np.argmax(results["logits"][0, -1]))
|
|
86
|
+
if token == EOS_ID:
|
|
87
|
+
break
|
|
88
|
+
generated.append(token)
|
|
89
|
+
pasts = {
|
|
90
|
+
name: results[name.replace("past_", "present_", 1)]
|
|
91
|
+
for name in pasts
|
|
92
|
+
}
|
|
93
|
+
current = np.array([[token]], dtype=np.int64)
|
|
94
|
+
return generated
|
|
95
|
+
|
|
96
|
+
def _greedy_plain(self, hidden, max_len: int) -> list[int]:
|
|
97
|
+
decoder_ids = np.array([[PAD_ID]], dtype=np.int64)
|
|
98
|
+
generated: list[int] = []
|
|
99
|
+
for _ in range(max_len):
|
|
100
|
+
logits = self._decoder.run(
|
|
101
|
+
None,
|
|
102
|
+
{"input_ids": decoder_ids, "encoder_hidden_states": hidden},
|
|
103
|
+
)[0]
|
|
104
|
+
token = int(np.argmax(logits[0, -1]))
|
|
105
|
+
if token == EOS_ID:
|
|
106
|
+
break
|
|
107
|
+
generated.append(token)
|
|
108
|
+
decoder_ids = np.concatenate(
|
|
109
|
+
[decoder_ids, np.array([[token]], dtype=np.int64)], axis=1
|
|
110
|
+
)
|
|
111
|
+
return generated
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _providers() -> list[str]:
|
|
115
|
+
import onnxruntime as ort
|
|
116
|
+
|
|
117
|
+
available = ort.get_available_providers()
|
|
118
|
+
preferred = [p for p in ("CPUExecutionProvider",) if p in available]
|
|
119
|
+
return preferred or available
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _zero_past(meta) -> object:
|
|
123
|
+
shape = meta.shape # [batch, heads, past_seq, d_kv], dynamic dims are str
|
|
124
|
+
heads = shape[1] if isinstance(shape[1], int) else 4
|
|
125
|
+
d_kv = shape[3] if isinstance(shape[3], int) else 8
|
|
126
|
+
dtype = np.float16 if meta.type == "tensor(float16)" else np.float32
|
|
127
|
+
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
|
|
@@ -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,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: secryst
|
|
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
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=1.26
|
|
12
|
+
Requires-Dist: pyyaml>=6.0
|
|
13
|
+
Requires-Dist: onnxruntime>=1.17
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
16
|
+
Requires-Dist: onnx>=1.16; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# secryst — Python crystal
|
|
20
|
+
|
|
21
|
+
**Secryst** is coined from *scrying* + *crystal*: gazing into an opaque
|
|
22
|
+
script to reveal its hidden reading. This package is the Python crystal.
|
|
23
|
+
|
|
24
|
+
The Python crystal — reference implementation of **IMF v1** model zips
|
|
25
|
+
and the `models.yaml` index (the **interscript-ml** contract), the
|
|
26
|
+
phonological layer of Interscript. The Ruby (`secryst` gem) and
|
|
27
|
+
TypeScript (`npm i secryst`) crystals are diffed against this one on
|
|
28
|
+
shared golden sets. This crystal owns golden generation and numerical
|
|
29
|
+
adjudication for the family.
|
|
30
|
+
|
|
31
|
+
Home repo: https://github.com/secryst/secryst-py (this copy in
|
|
32
|
+
ml-models/runtime is the frozen origin; the package now lives there).
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from secryst import Model
|
|
36
|
+
|
|
37
|
+
model = Model.load("khm-latn-1.0") # id: index resolve -> download
|
|
38
|
+
# -> sha256-verify -> cache -> load
|
|
39
|
+
model.translate("ភាសា") # -> "pheasaea"
|
|
40
|
+
model.id # "khm-latn-1.0"
|
|
41
|
+
|
|
42
|
+
model = Model.load("khm-latn-1.0.zip") # or: a local zip path directly
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Byte-level only: the canonical ByT5 table (byte `b` → id `b+3`,
|
|
46
|
+
trailing EOS) — no vocab files, no per-model tokenization code.
|
|
47
|
+
- Greedy KV-cache decode when the zip ships `decoder-kv.onnx`
|
|
48
|
+
(default), plain full-recompute fallback otherwise.
|
|
49
|
+
- Every `.onnx` member is sha256-verified against `metadata.yaml`
|
|
50
|
+
before the session is created; corrupt downloads fail loudly.
|
|
51
|
+
- Dynamic fetch per the `models.yaml` contract (shared with the Ruby and
|
|
52
|
+
TypeScript runtimes): resolve id -> channel URL, download to temp,
|
|
53
|
+
verify whole-file sha256 against the index, atomically install into
|
|
54
|
+
`~/.cache/secryst/models/<id>/`. Overrides:
|
|
55
|
+
`SECRYST_INDEX` (URL or path), `SECRYST_CACHE`.
|
|
56
|
+
|
|
57
|
+
Install: `pip install secryst` (or `pip install -e ".[dev]"` from the repo).
|
|
58
|
+
|
|
59
|
+
Tests: `python -m pytest runtime/tests` — tiny-graph zips, no torch
|
|
60
|
+
needed. The end-to-end golden test runs when `SECRYST_E2E_ZIP`
|
|
61
|
+
points at a real zip (e.g. `models/khm-latn/khm-latn-1.0-fp32.zip`)
|
|
62
|
+
and asserts byte-identical outputs against `golden/khm-latn-100.jsonl`.
|
|
63
|
+
|
|
64
|
+
License: BSD-3-Clause.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/secryst/__init__.py
|
|
5
|
+
src/secryst/loader.py
|
|
6
|
+
src/secryst/model.py
|
|
7
|
+
src/secryst/registry.py
|
|
8
|
+
src/secryst/tokens.py
|
|
9
|
+
src/secryst.egg-info/PKG-INFO
|
|
10
|
+
src/secryst.egg-info/SOURCES.txt
|
|
11
|
+
src/secryst.egg-info/dependency_links.txt
|
|
12
|
+
src/secryst.egg-info/requires.txt
|
|
13
|
+
src/secryst.egg-info/top_level.txt
|
|
14
|
+
tests/test_model.py
|
|
15
|
+
tests/test_registry.py
|
|
16
|
+
tests/tests_helpers.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
secryst
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tests for the interscript-ml runtime.
|
|
2
|
+
|
|
3
|
+
Tiny-graph zips built with the onnx package (no torch, no training
|
|
4
|
+
repo). The end-to-end golden test runs only when a real zip is provided
|
|
5
|
+
via SECRYST_E2E_ZIP.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
ort = pytest.importorskip("onnxruntime")
|
|
20
|
+
onnx = pytest.importorskip("onnx")
|
|
21
|
+
|
|
22
|
+
from secryst import Model, ModelFormatError, decode, encode # noqa: E402
|
|
23
|
+
from onnx import TensorProto, helper, numpy_helper # noqa: E402
|
|
24
|
+
|
|
25
|
+
import numpy as np # noqa: E402
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _graph(opset: int = 14) -> bytes:
|
|
29
|
+
graph = helper.make_graph(
|
|
30
|
+
nodes=[helper.make_node("Add", ["input_ids", "bias"], ["last_hidden_state"])],
|
|
31
|
+
name="tiny-enc",
|
|
32
|
+
inputs=[
|
|
33
|
+
helper.make_tensor_value_info("input_ids", TensorProto.INT64, ["batch", "seq"])
|
|
34
|
+
],
|
|
35
|
+
outputs=[
|
|
36
|
+
helper.make_tensor_value_info(
|
|
37
|
+
"last_hidden_state", TensorProto.INT64, ["batch", "seq"]
|
|
38
|
+
)
|
|
39
|
+
],
|
|
40
|
+
initializer=[numpy_helper.from_array(np.zeros(1, dtype=np.int64), "bias")],
|
|
41
|
+
)
|
|
42
|
+
model = helper.make_model(
|
|
43
|
+
graph, opset_imports=[helper.make_opsetid("", opset)], ir_version=7
|
|
44
|
+
)
|
|
45
|
+
return model.SerializeToString()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _decoder_graph() -> bytes:
|
|
49
|
+
graph = helper.make_graph(
|
|
50
|
+
nodes=[
|
|
51
|
+
helper.make_node("Add", ["input_ids", "bias"], ["logits"])
|
|
52
|
+
],
|
|
53
|
+
name="tiny-dec",
|
|
54
|
+
inputs=[
|
|
55
|
+
helper.make_tensor_value_info("input_ids", TensorProto.INT64, ["batch", "seq"]),
|
|
56
|
+
helper.make_tensor_value_info(
|
|
57
|
+
"encoder_hidden_states", TensorProto.INT64, ["batch", "seq"]
|
|
58
|
+
),
|
|
59
|
+
],
|
|
60
|
+
outputs=[
|
|
61
|
+
helper.make_tensor_value_info("logits", TensorProto.INT64, ["batch", "seq"])
|
|
62
|
+
],
|
|
63
|
+
initializer=[numpy_helper.from_array(np.zeros(1, dtype=np.int64), "bias")],
|
|
64
|
+
)
|
|
65
|
+
model = helper.make_model(
|
|
66
|
+
graph, opset_imports=[helper.make_opsetid("", 14)], ir_version=7
|
|
67
|
+
)
|
|
68
|
+
return model.SerializeToString()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
MANIFEST = {
|
|
72
|
+
"format": "imf-v1",
|
|
73
|
+
"id": "tiny-1.0",
|
|
74
|
+
"task": "translit",
|
|
75
|
+
"source_script": "Latn",
|
|
76
|
+
"target": "Latn",
|
|
77
|
+
"tokenizer": "bytes",
|
|
78
|
+
"opset": 14,
|
|
79
|
+
"decoder": "plain",
|
|
80
|
+
"precision": "fp32",
|
|
81
|
+
"license": "BSD-3-Clause",
|
|
82
|
+
"trained_from": "runtime test fixture",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _tiny_zip(path: Path, tamper: bool = False, manifest: dict | None = None) -> Path:
|
|
87
|
+
encoder, decoder = _graph(), _decoder_graph()
|
|
88
|
+
sha = {
|
|
89
|
+
"encoder.onnx": hashlib.sha256(encoder).hexdigest(),
|
|
90
|
+
"decoder.onnx": hashlib.sha256(decoder).hexdigest(),
|
|
91
|
+
}
|
|
92
|
+
if tamper:
|
|
93
|
+
sha["encoder.onnx"] = "0" * 64
|
|
94
|
+
meta = dict(manifest if manifest is not None else MANIFEST)
|
|
95
|
+
meta["sha256"] = sha
|
|
96
|
+
with zipfile.ZipFile(path, "w") as zf:
|
|
97
|
+
zf.writestr("metadata.yaml", yaml.safe_dump(meta))
|
|
98
|
+
zf.writestr("encoder.onnx", encoder)
|
|
99
|
+
zf.writestr("decoder.onnx", decoder)
|
|
100
|
+
zf.writestr("README.md", "# tiny\n")
|
|
101
|
+
return path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_token_table() -> None:
|
|
105
|
+
assert encode("rok") == [117, 114, 110, 1]
|
|
106
|
+
assert decode([117, 114, 110]) == "rok"
|
|
107
|
+
assert decode([117, 1, 114]) == "r"
|
|
108
|
+
assert decode([]) == ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_load_and_decode_tiny(tmp_path: Path) -> None:
|
|
112
|
+
z = _tiny_zip(tmp_path / "tiny.zip")
|
|
113
|
+
model = Model.load(z)
|
|
114
|
+
assert model.id == "tiny-1.0"
|
|
115
|
+
# tiny graphs are identity Adds: logits echo the decoder prefix, so
|
|
116
|
+
# greedy emits encode(PAD-prefix input)+... — deterministic, not
|
|
117
|
+
# meaningful; what matters is that the loop runs and decodes.
|
|
118
|
+
tokens = model.generate("he", max_len=4)
|
|
119
|
+
assert isinstance(tokens, list)
|
|
120
|
+
text = model.translate("he", max_len=4)
|
|
121
|
+
assert isinstance(text, str)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_sha256_mismatch_rejected(tmp_path: Path) -> None:
|
|
125
|
+
z = _tiny_zip(tmp_path / "bad.zip", tamper=True)
|
|
126
|
+
with pytest.raises(ModelFormatError, match="sha256 mismatch"):
|
|
127
|
+
Model.load(z)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_non_bytes_tokenizer_rejected(tmp_path: Path) -> None:
|
|
131
|
+
manifest = dict(MANIFEST, tokenizer="sentencepiece")
|
|
132
|
+
z = _tiny_zip(tmp_path / "spm.zip", manifest=manifest)
|
|
133
|
+
with pytest.raises(ModelFormatError, match="byte-level only"):
|
|
134
|
+
Model.load(z)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_missing_graph_rejected(tmp_path: Path) -> None:
|
|
138
|
+
z = _tiny_zip(tmp_path / "m.zip")
|
|
139
|
+
truncated = tmp_path / "trunc.zip"
|
|
140
|
+
with zipfile.ZipFile(z) as src, zipfile.ZipFile(truncated, "w") as dst:
|
|
141
|
+
for name in src.namelist():
|
|
142
|
+
if name != "decoder.onnx":
|
|
143
|
+
dst.writestr(name, src.read(name))
|
|
144
|
+
with pytest.raises(ModelFormatError, match="decoder.onnx"):
|
|
145
|
+
Model.load(truncated)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_golden_set_e2e() -> None:
|
|
149
|
+
"""Run against a real zip: byte-identical outputs on the golden set."""
|
|
150
|
+
zip_path = os.environ.get("SECRYST_E2E_ZIP")
|
|
151
|
+
if not zip_path:
|
|
152
|
+
pytest.skip("set SECRYST_E2E_ZIP to a real IMF zip")
|
|
153
|
+
golden = Path(__file__).resolve().parent.parent.parent / "golden" / "khm-latn-100.jsonl"
|
|
154
|
+
if "khm" not in Path(zip_path).name:
|
|
155
|
+
pytest.skip("golden file is khm-latn specific")
|
|
156
|
+
model = Model.load(zip_path)
|
|
157
|
+
rows = [json.loads(line) for line in golden.read_text(encoding="utf-8").splitlines()]
|
|
158
|
+
for row in rows:
|
|
159
|
+
assert model.translate(row["input"], max_len=128) == row["output"], row["input"]
|
|
@@ -0,0 +1,170 @@
|
|
|
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 secryst.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["SECRYST_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("SECRYST_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["SECRYST_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("SECRYST_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["SECRYST_CACHE"] = str(tmp_path / "cache")
|
|
75
|
+
try:
|
|
76
|
+
from secryst 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("SECRYST_CACHE", None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_resolve_parts_assembles_and_verifies(tmp_path: Path) -> None:
|
|
86
|
+
import hashlib
|
|
87
|
+
|
|
88
|
+
zip_path = build_tiny_zip(tmp_path / "channel" / "tiny.zip")
|
|
89
|
+
blob = zip_path.read_bytes()
|
|
90
|
+
part_a, part_b = blob[: len(blob) // 2 + 3], blob[len(blob) // 2 + 3 :]
|
|
91
|
+
channel = tmp_path / "channel"
|
|
92
|
+
(channel / "tiny.zip.part-00").write_bytes(part_a)
|
|
93
|
+
(channel / "tiny.zip.part-01").write_bytes(part_b)
|
|
94
|
+
index = {
|
|
95
|
+
"version": 1,
|
|
96
|
+
"models": {
|
|
97
|
+
"tiny-1.0": {
|
|
98
|
+
"task": "translit",
|
|
99
|
+
"precision": "fp32",
|
|
100
|
+
"filename": "tiny.zip",
|
|
101
|
+
"sha256": hashlib.sha256(blob).hexdigest(),
|
|
102
|
+
"size": len(blob),
|
|
103
|
+
"parts": [
|
|
104
|
+
{
|
|
105
|
+
"url": f"file://{channel / 'tiny.zip.part-00'}",
|
|
106
|
+
"sha256": hashlib.sha256(part_a).hexdigest(),
|
|
107
|
+
"size": len(part_a),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"url": f"file://{channel / 'tiny.zip.part-01'}",
|
|
111
|
+
"sha256": hashlib.sha256(part_b).hexdigest(),
|
|
112
|
+
"size": len(part_b),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
index_path = tmp_path / "models.yaml"
|
|
119
|
+
index_path.write_text(yaml.safe_dump(index), encoding="utf-8")
|
|
120
|
+
cache = tmp_path / "cache"
|
|
121
|
+
os.environ["SECRYST_CACHE"] = str(cache)
|
|
122
|
+
try:
|
|
123
|
+
local = resolve("tiny-1.0", index_url=str(index_path))
|
|
124
|
+
assert local == cache / "models" / "tiny-1.0" / "tiny.zip"
|
|
125
|
+
assert local.read_bytes() == blob
|
|
126
|
+
zip_path.unlink()
|
|
127
|
+
(channel / "tiny.zip.part-00").unlink()
|
|
128
|
+
assert resolve("tiny-1.0", index_url=str(index_path)) == local
|
|
129
|
+
finally:
|
|
130
|
+
os.environ.pop("SECRYST_CACHE", None)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_resolve_parts_rejects_corrupt_part(tmp_path: Path) -> None:
|
|
134
|
+
import hashlib
|
|
135
|
+
|
|
136
|
+
zip_path = build_tiny_zip(tmp_path / "channel" / "tiny.zip")
|
|
137
|
+
blob = zip_path.read_bytes()
|
|
138
|
+
part_a, part_b = blob[:7], blob[7:]
|
|
139
|
+
channel = tmp_path / "channel"
|
|
140
|
+
(channel / "tiny.zip.part-00").write_bytes(part_a)
|
|
141
|
+
(channel / "tiny.zip.part-01").write_bytes(part_b)
|
|
142
|
+
index = {
|
|
143
|
+
"version": 1,
|
|
144
|
+
"models": {
|
|
145
|
+
"tiny-1.0": {
|
|
146
|
+
"filename": "tiny.zip",
|
|
147
|
+
"sha256": hashlib.sha256(blob).hexdigest(),
|
|
148
|
+
"parts": [
|
|
149
|
+
{
|
|
150
|
+
"url": f"file://{channel / 'tiny.zip.part-00'}",
|
|
151
|
+
"sha256": "0" * 64,
|
|
152
|
+
"size": len(part_a),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"url": f"file://{channel / 'tiny.zip.part-01'}",
|
|
156
|
+
"sha256": hashlib.sha256(part_b).hexdigest(),
|
|
157
|
+
"size": len(part_b),
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
index_path = tmp_path / "models.yaml"
|
|
164
|
+
index_path.write_text(yaml.safe_dump(index), encoding="utf-8")
|
|
165
|
+
os.environ["SECRYST_CACHE"] = str(tmp_path / "cache")
|
|
166
|
+
try:
|
|
167
|
+
with pytest.raises(RegistryError, match="part 0"):
|
|
168
|
+
resolve("tiny-1.0", index_url=str(index_path))
|
|
169
|
+
finally:
|
|
170
|
+
os.environ.pop("SECRYST_CACHE", None)
|
|
@@ -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
|