Skip to content

Commit 1305ff8

Browse files
committed
feat: neural layer as Interscript::ML - the secryst crystal folds into interscript
Ports the secryst gem's IMF runtime (imf/provisioning/translator/model/ vocab/byt5_onnx) as lib/interscript/ml with autoload entries; the SecrystAdapter now dispatches through the built-in runtime instead of requiring the external gem (rubyzip/onnxruntime load lazily with helpful ExternalUtilError guidance). Index pinned at index-v7; INTERSCRIPT_ML_INDEX/CACHE/DATA primary env vars with SECRYST_* legacy aliases. 13 ported ml specs + rewritten stdlib funcall spec (real-instance, offline via the fixture zip path short-circuit): 308 examples, 0 failures under the canonical SKIP_JS/SKIP_PYTHON run. CI bundles the :ml group (was :secryst) so specs get rubyzip+onnxruntime.
1 parent e4096bf commit 1305ff8

17 files changed

Lines changed: 919 additions & 30 deletions

File tree

‎.github/workflows/assets.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
assets:
1111
runs-on: ubuntu-latest
1212
env:
13-
BUNDLE_WITHOUT: "secryst:jsexec"
13+
BUNDLE_WITHOUT: "jsexec"
1414
SKIP_JS: "1"
1515
steps:
1616
- name: Checkout monorepo
@@ -30,7 +30,7 @@ jobs:
3030

3131
- name: Install bundle
3232
working-directory: ruby
33-
run: bundle install --jobs 4 --retry 3 --with jsexec --without secryst
33+
run: bundle install --jobs 4 --retry 3 --with jsexec
3434

3535
- name: Generate visualization JSON
3636
working-directory: ruby

‎.github/workflows/rake.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
ruby: ["3.3", "3.4"]
1717
os: [ubuntu-latest, windows-latest, macos-latest]
1818
env:
19-
BUNDLE_WITHOUT: "secryst"
19+
BUNDLE_WITHOUT: "jsexec"
2020
SKIP_JS: "1"
2121
ISC_SWEEP: "1"
2222
# The ISC corpus sweep: every map's own tests through every

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131

3232
- name: Install gems
3333
working-directory: ruby
34-
run: bundle install --jobs 4 --retry 3 --with jsexec --without secryst
34+
run: bundle install --jobs 4 --retry 3 --with jsexec
3535

3636
- name: Test Ruby package
3737
working-directory: ruby

‎Gemfile‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,10 @@ else
1616
gem "interscript-maps"
1717
end
1818

19-
group :secryst do
20-
if File.exist?("../../secryst")
21-
gem "secryst", path: "../../secryst"
22-
else
23-
gem "secryst"
24-
end
19+
# The neural layer (Interscript::ML) - optional at runtime, loaded for specs
20+
group :ml do
21+
gem "rubyzip"
22+
gem "onnxruntime"
2523
end
2624

2725
gem "regexp_parser"

‎lib/interscript.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module Interscript
1212
autoload :Node, "interscript/node"
1313
autoload :Detector, "interscript/detector"
1414
autoload :Isc, "interscript/isc"
15+
autoload :ML, "interscript/ml"
1516

1617
# An error caused by a lack of some map
1718
class MapNotFoundError < StandardError; end

‎lib/interscript/ml.rb‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
# The neural layer: IMF v1 artifact runtime + index provisioning for
4+
# byte-level ByT5 models, ported from the secryst crystal (1.1.1) into
5+
# interscript proper. Runtime dependencies rubyzip and onnxruntime are
6+
# OPTIONAL: they load lazily with a helpful error when absent.
7+
#
8+
# Interscript::ML::Translator.new(model_file: "khm-latn-1.0")
9+
# .translate("ភាសា")
10+
module Interscript::ML
11+
autoload :IMF, "interscript/ml/imf"
12+
autoload :Provisioning, "interscript/ml/provisioning"
13+
autoload :Translator, "interscript/ml/translator"
14+
autoload :Model, "interscript/ml/model"
15+
autoload :Vocab, "interscript/ml/vocab"
16+
autoload :Byt5Onnx, "interscript/ml/byt5_onnx"
17+
18+
# Expected to be loaded before IMF/Byt5Onnx entry points.
19+
def self.require_optional!(gem_name, feature: gem_name)
20+
require feature
21+
rescue LoadError => e
22+
raise Interscript::ExternalUtilError,
23+
"the neural layer needs the #{gem_name} gem: gem install #{gem_name} (or use a bundle including it)"
24+
end
25+
end

‎lib/interscript/ml/byt5_onnx.rb‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
require 'tmpdir'
2+
3+
module Interscript::ML
4+
# Byte-level seq2seq (ByT5 family) inference over ONNX sessions from an
5+
# IMF v1 model zip: sha256-verified graphs, greedy KV-cache decode when
6+
# the zip ships decoder-kv.onnx (default), plain full-recompute
7+
# fallback otherwise. Tokenization is the canonical ByT5 table
8+
# (byte b -> id b+3, trailing EOS) — see Interscript::ML::IMF.
9+
class Byt5Onnx
10+
def initialize(zip_path)
11+
Interscript::ML.require_optional!("onnxruntime")
12+
@manifest = IMF.manifest(zip_path)
13+
graphs = IMF.verify_and_read(zip_path)
14+
@tmpdir = Dir.mktmpdir('secryst-imf')
15+
@encoder = build_session(graphs, 'encoder.onnx')
16+
@kv = @manifest['decoder'] == 'kv' && graphs.key?('decoder-kv.onnx')
17+
@decoder = build_session(graphs, @kv ? 'decoder-kv.onnx' : 'decoder.onnx')
18+
@pasts = @kv ? zero_pasts : {}
19+
end
20+
21+
def translate(text, max_seq_length: 256)
22+
ids = IMF.encode(text)
23+
return '' if ids.length == 1 # only the trailing EOS: empty input
24+
25+
hidden = @encoder.predict({ input_ids: [ids] })['last_hidden_state']
26+
tokens = @kv ? greedy_kv(hidden, max_seq_length) : greedy_plain(hidden, max_seq_length)
27+
IMF.decode(tokens)
28+
end
29+
30+
def id
31+
@manifest['id']
32+
end
33+
34+
private
35+
36+
# The onnxruntime gem loads from paths, not bytes: verified bytes go
37+
# to a private tmpdir.
38+
def build_session(graphs, name)
39+
path = File.join(@tmpdir, name)
40+
File.binwrite(path, graphs.fetch(name))
41+
OnnxRuntime::Model.new(path)
42+
end
43+
44+
def zero_pasts
45+
@decoder.inputs.each_with_object({}) do |meta, pasts|
46+
next unless meta[:name].start_with?('past_')
47+
shape = meta[:shape] # [batch, heads, past_seq, d_kv]; dynamic dims are Strings
48+
heads = shape[1].is_a?(Integer) ? shape[1] : 4
49+
d_kv = shape[3].is_a?(Integer) ? shape[3] : 8
50+
# Numo carries the explicit [1, heads, 0, d_kv] shape through;
51+
# nested Ruby arrays cannot express a zero-length dim.
52+
pasts[meta[:name]] = Numo::DFloat.zeros(1, heads, 0, d_kv)
53+
end
54+
end
55+
56+
def argmax(row)
57+
best = 0
58+
best_val = -Float::INFINITY
59+
row.each_with_index do |value, index|
60+
if value > best_val
61+
best_val = value
62+
best = index
63+
end
64+
end
65+
best
66+
end
67+
68+
def greedy_kv(hidden, max_seq_length)
69+
pasts = @pasts.dup
70+
current = [IMF::PAD_ID]
71+
generated = []
72+
max_seq_length.times do
73+
results = @decoder.predict(
74+
{ input_ids: [current], encoder_hidden_states: hidden }.merge(pasts)
75+
)
76+
token = argmax(results['logits'].first.last)
77+
break if token == IMF::EOS_ID
78+
79+
generated << token
80+
pasts = pasts.keys.to_h { |name| [name, results[name.sub('past_', 'present_')]] }
81+
current = [token]
82+
end
83+
generated
84+
end
85+
86+
def greedy_plain(hidden, max_seq_length)
87+
decoder_ids = [IMF::PAD_ID]
88+
generated = []
89+
max_seq_length.times do
90+
logits = @decoder.predict(
91+
{ input_ids: [decoder_ids], encoder_hidden_states: hidden }
92+
)['logits']
93+
token = argmax(logits.first.last)
94+
break if token == IMF::EOS_ID
95+
96+
generated << token
97+
decoder_ids = decoder_ids + [token]
98+
end
99+
generated
100+
end
101+
end
102+
end

‎lib/interscript/ml/imf.rb‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
require "yaml"
2+
require "digest/sha2"
3+
require "fileutils"
4+
require "open-uri"
5+
6+
module Interscript::ML
7+
# Interscript Model Format v1 — the byte-level runtime contract shared
8+
# with the Python (interscript-ml) and TypeScript (npm: secryst)
9+
# runtimes. Token ids follow the canonical ByT5 table: byte b -> b+3,
10+
# trailing EOS; pad=0, unk=2. Ids are NOT raw byte values.
11+
module IMF
12+
BYTE_OFFSET = 3
13+
PAD_ID = 0
14+
EOS_ID = 1
15+
UNK_ID = 2
16+
17+
DEFAULT_INDEX_URL = "https://github.com/interscript/interscript-ml/releases/download/index-v7/models-index.yaml"
18+
19+
class FormatError < StandardError; end
20+
class RegistryError < StandardError; end
21+
22+
class << self
23+
def require_zip!
24+
Interscript::ML.require_optional!("rubyzip", feature: "zip")
25+
end
26+
27+
def encode(text)
28+
text.bytes.map { |b| b + BYTE_OFFSET } + [EOS_ID]
29+
end
30+
31+
def decode(token_ids)
32+
out = +""
33+
token_ids.each do |token|
34+
break if token == EOS_ID
35+
next if token == PAD_ID || token == UNK_ID
36+
out << ((token - BYTE_OFFSET) % 256).chr
37+
end
38+
out.force_encoding(Encoding::UTF_8)
39+
end
40+
41+
def manifest(zip_path)
42+
require_zip!
43+
Zip::File.open(zip_path) do |zf|
44+
raise FormatError, "missing metadata.yaml" unless zf.find_entry("metadata.yaml")
45+
meta = YAML.safe_load(zf.read("metadata.yaml"), permitted_classes: [], aliases: false)
46+
raise FormatError, "unsupported format: #{meta["format"].inspect}" if meta["format"] != "imf-v1"
47+
if meta["tokenizer"] != "bytes"
48+
raise FormatError, "tokenizer #{meta["tokenizer"].inspect}: this runtime is byte-level only"
49+
end
50+
%w[encoder.onnx decoder.onnx].each do |required|
51+
raise FormatError, "missing #{required}" unless zf.find_entry(required)
52+
end
53+
meta
54+
end
55+
end
56+
57+
# Reads every .onnx member after verifying its sha256 against the
58+
# manifest — corrupt zips fail loudly, before any session loads.
59+
def verify_and_read(zip_path)
60+
meta = manifest(zip_path)
61+
sha = meta.fetch("sha256", {})
62+
graphs = {}
63+
require_zip!
64+
Zip::File.open(zip_path) do |zf|
65+
zf.entries.select { |e| e.name.end_with?(".onnx") }.each do |entry|
66+
recorded = sha[entry.name]
67+
raise FormatError, "#{entry.name} is not covered by metadata sha256" unless recorded
68+
bytes = entry.get_input_stream.read
69+
actual = Digest::SHA256.hexdigest(bytes)
70+
if actual != recorded
71+
raise FormatError, "#{entry.name} sha256 mismatch: zip has #{actual}, metadata says #{recorded}"
72+
end
73+
graphs[entry.name] = bytes
74+
end
75+
end
76+
graphs
77+
end
78+
79+
def cache_dir
80+
ENV["INTERSCRIPT_ML_CACHE"] || ENV["SECRYST_CACHE"] || File.join(Dir.home, ".cache", "interscript-ml")
81+
end
82+
83+
# models.yaml resolution: cache hit (re-verified), or download ->
84+
# verify whole-file sha256 -> atomic install into the cache.
85+
# Entries with `parts` (GitHub's 2 GiB per-asset cap) are streamed
86+
# in order, each part sha256-verified as it lands, then the
87+
# assembled file is checked against the whole-file sha256.
88+
def resolve(model_id, index_url: nil)
89+
source = index_url || ENV["INTERSCRIPT_ML_INDEX"] || ENV["SECRYST_INDEX"] || DEFAULT_INDEX_URL
90+
entries = load_index(source)
91+
entry = entries[model_id]
92+
raise RegistryError, "unknown model id #{model_id.inspect} (known: #{entries.keys.sort})" unless entry
93+
94+
target = File.join(cache_dir, "models", model_id, entry["filename"])
95+
if File.file?(target) && Digest::SHA256.file(target).hexdigest == entry["sha256"]
96+
return target
97+
end
98+
99+
FileUtils.mkdir_p(File.dirname(target))
100+
tmp = target + ".part.#{Process.pid}"
101+
if entry["parts"]
102+
download_parts(entry, tmp)
103+
else
104+
channel = entry["url"]
105+
if channel.start_with?("file://")
106+
FileUtils.cp(channel.sub(%r{\Afile://}, ""), tmp)
107+
else
108+
URI.open(channel) { |remote| IO.copy_stream(remote, tmp) }
109+
end
110+
end
111+
actual = Digest::SHA256.file(tmp).hexdigest
112+
unless actual == entry["sha256"]
113+
File.delete(tmp)
114+
raise RegistryError, "downloaded #{entry["filename"]} sha256 mismatch: got #{actual}, index says #{entry["sha256"]}"
115+
end
116+
File.rename(tmp, target)
117+
target
118+
end
119+
120+
private
121+
122+
def download_parts(entry, tmp)
123+
File.open(tmp, "wb") do |out|
124+
entry["parts"].each_with_index do |part, index|
125+
digest = Digest::SHA256.new
126+
source = part["url"].start_with?("file://") ? part["url"].sub(%r{\Afile://}, "") : part["url"]
127+
open_stream = lambda do |io|
128+
while (chunk = io.read(1024 * 1024))
129+
out.write(chunk)
130+
digest.update(chunk)
131+
end
132+
end
133+
if part["url"].start_with?("file://")
134+
File.open(source, "rb", &open_stream)
135+
else
136+
URI.open(source, "rb", &open_stream)
137+
end
138+
unless digest.hexdigest == part["sha256"]
139+
raise RegistryError, "part #{index} of #{entry["filename"]} sha256 mismatch: got #{digest.hexdigest}, index says #{part["sha256"]}"
140+
end
141+
end
142+
end
143+
rescue StandardError
144+
File.delete(tmp) if File.file?(tmp)
145+
raise
146+
end
147+
148+
def load_index(source)
149+
text = if source.start_with?("http://", "https://")
150+
fetch_index_with_sidecar(source)
151+
else
152+
File.read(source)
153+
end
154+
raw = YAML.safe_load(text, permitted_classes: [], aliases: false)
155+
raise RegistryError, "index must have version: 1" if raw["version"] != 1
156+
raw.fetch("models", {})
157+
end
158+
public :load_index
159+
160+
# HTTP sources must ship a sibling .sha256 sidecar; verify the
161+
# body against it before parsing (GitHub Releases index assets do).
162+
def fetch_index_with_sidecar(source)
163+
body = URI.open(source, "rb") { |remote| remote.read }
164+
sidecar =
165+
begin
166+
URI.open("#{source}.sha256") { |remote| remote.read }
167+
rescue OpenURI::HTTPError => e
168+
raise RegistryError, "index sha256 sidecar missing: #{source}.sha256 (#{e.message})"
169+
end
170+
expected = sidecar.to_s.strip.split(/\s+/).first.to_s
171+
unless expected.match?(/\A[0-9a-fA-F]{64}\z/)
172+
raise RegistryError, "index sha256 sidecar malformed: #{source}.sha256"
173+
end
174+
actual = Digest::SHA256.hexdigest(body)
175+
unless actual == expected.downcase
176+
raise RegistryError, "index sha256 mismatch: got #{actual}, sidecar says #{expected.downcase}"
177+
end
178+
body.force_encoding(Encoding::UTF_8)
179+
end
180+
end
181+
end
182+
end

0 commit comments

Comments
 (0)