interscript-ml 0.1.0__py3-none-any.whl

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