secryst 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.
secryst/__init__.py 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
+ ]
secryst/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
secryst/model.py ADDED
@@ -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)
secryst/registry.py ADDED
@@ -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
secryst/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,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,10 @@
1
+ secryst/__init__.py,sha256=Hi5dW1yL53PJRaRsZr-zfdrwvqJb_mvtftxGUw3HQuo,920
2
+ secryst/loader.py,sha256=lau1Ed9EXmhRdGiwKt_BuYApZrfAO1bXvo6rUZ9_NgU,2321
3
+ secryst/model.py,sha256=f3cTyMXv4upkhxVs4O3Pk4HqLNYvmNefwUkf50kU9vA,4635
4
+ secryst/registry.py,sha256=qmy7NTgXXHZaVzCt6h4VC1SGImIbn57KQLPFwbVzYh4,5303
5
+ secryst/tokens.py,sha256=UV59DlvAte1VDZRB_rW7hQ2dOCgIiMRWkvcoJ3N9nTk,884
6
+ secryst-0.1.0.dist-info/licenses/LICENSE,sha256=LFo4QQ3Y_GqnVZZOhzYgdpSf_1g9l3vxyr_9gkPfNZQ,1519
7
+ secryst-0.1.0.dist-info/METADATA,sha256=eai3zIwqP8ylvEKsFNBfyhgNcBpm5gsDTEUodjzg_UQ,2705
8
+ secryst-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ secryst-0.1.0.dist-info/top_level.txt,sha256=Db8KQXzYu93gr7hdlAJNuIerhfoyq37zMko1zw_6Wvk,8
10
+ secryst-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ secryst