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 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.
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+ numpy>=1.26
2
+ pyyaml>=6.0
3
+ onnxruntime>=1.17
4
+
5
+ [dev]
6
+ pytest>=8.0
7
+ onnx>=1.16
@@ -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