Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/rake.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ jobs:

- name: Bootstrap packages
run: ruby bootstrap.rb
# bootstrap clones interscript-ruby REMOTE MAIN into ruby/ — for
# PRs that would test main's code instead of the PR. Overlay the
# actual ref under test onto the bootstrapped sibling layout.
- name: Overlay the ref under test onto ruby/
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref || github.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
path: ruby
clean: false

- name: Set up Ruby
uses: ruby/setup-ruby@v1
Expand Down
1 change: 0 additions & 1 deletion lib/interscript/isc/grammar/isc.artifact.json

This file was deleted.

2 changes: 1 addition & 1 deletion lib/interscript/ml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ module Interscript::ML
# Expected to be loaded before IMF/Byt5Onnx/PlaneModel entry points.
def self.require_optional!(gem_name, feature: gem_name)
require feature
rescue LoadError => e
rescue LoadError
raise Interscript::ExternalUtilError,
"the neural layer needs the #{gem_name} gem: gem install #{gem_name} (or use a bundle including it)"
end
Expand Down
30 changes: 15 additions & 15 deletions lib/interscript/ml/byt5_onnx.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
require 'tmpdir'
require "tmpdir"

module Interscript::ML
# Byte-level seq2seq (ByT5 family) inference over ONNX sessions from an
Expand All @@ -11,24 +11,24 @@ def initialize(zip_path)
Interscript::ML.require_optional!("onnxruntime")
@manifest = IMF.manifest(zip_path)
graphs = IMF.verify_and_read(zip_path)
@tmpdir = Dir.mktmpdir('secryst-imf')
@encoder = build_session(graphs, 'encoder.onnx')
@kv = @manifest['decoder'] == 'kv' && graphs.key?('decoder-kv.onnx')
@decoder = build_session(graphs, @kv ? 'decoder-kv.onnx' : 'decoder.onnx')
@tmpdir = Dir.mktmpdir("secryst-imf")
@encoder = build_session(graphs, "encoder.onnx")
@kv = @manifest["decoder"] == "kv" && graphs.key?("decoder-kv.onnx")
@decoder = build_session(graphs, @kv ? "decoder-kv.onnx" : "decoder.onnx")
@pasts = @kv ? zero_pasts : {}
end

def translate(text, max_seq_length: 256)
ids = IMF.encode(text)
return '' if ids.length == 1 # only the trailing EOS: empty input
return "" if ids.length == 1 # only the trailing EOS: empty input

hidden = @encoder.predict({ input_ids: [ids] })['last_hidden_state']
hidden = @encoder.predict({input_ids: [ids]})["last_hidden_state"]
tokens = @kv ? greedy_kv(hidden, max_seq_length) : greedy_plain(hidden, max_seq_length)
IMF.first_alternates(IMF.decode(tokens))
end

def id
@manifest['id']
@manifest["id"]
end

private
Expand All @@ -43,7 +43,7 @@ def build_session(graphs, name)

def zero_pasts
@decoder.inputs.each_with_object({}) do |meta, pasts|
next unless meta[:name].start_with?('past_')
next unless meta[:name].start_with?("past_")
shape = meta[:shape] # [batch, heads, past_seq, d_kv]; dynamic dims are Strings
heads = shape[1].is_a?(Integer) ? shape[1] : 4
d_kv = shape[3].is_a?(Integer) ? shape[3] : 8
Expand Down Expand Up @@ -71,13 +71,13 @@ def greedy_kv(hidden, max_seq_length)
generated = []
max_seq_length.times do
results = @decoder.predict(
{ input_ids: [current], encoder_hidden_states: hidden }.merge(pasts)
{input_ids: [current], encoder_hidden_states: hidden}.merge(pasts)
)
token = argmax(results['logits'].first.last)
token = argmax(results["logits"].first.last)
break if token == IMF::EOS_ID

generated << token
pasts = pasts.keys.to_h { |name| [name, results[name.sub('past_', 'present_')]] }
pasts = pasts.keys.to_h { |name| [name, results[name.sub("past_", "present_")]] }
current = [token]
end
generated
Expand All @@ -88,13 +88,13 @@ def greedy_plain(hidden, max_seq_length)
generated = []
max_seq_length.times do
logits = @decoder.predict(
{ input_ids: [decoder_ids], encoder_hidden_states: hidden }
)['logits']
{input_ids: [decoder_ids], encoder_hidden_states: hidden}
)["logits"]
token = argmax(logits.first.last)
break if token == IMF::EOS_ID

generated << token
decoder_ids = decoder_ids + [token]
decoder_ids += [token]
end
generated
end
Expand Down
12 changes: 6 additions & 6 deletions lib/interscript/ml/imf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def encode(text)
# corpora emit '/'-separated alternates per token; the runtime
# returns the primary choice only.
def first_alternates(text)
text.split.map { |tok| tok.split('/', 2).first }.join(' ')
text.split.map { |tok| tok.split("/", 2).first }.join(" ")
end

def decode(token_ids)
Expand Down Expand Up @@ -112,7 +112,7 @@ def resolve(model_id, index_url: nil)
if channel.start_with?("file://")
FileUtils.cp(channel.sub(%r{\Afile://}, ""), tmp)
else
URI.open(channel) { |remote| IO.copy_stream(remote, tmp) }
URI.parse(channel).open { |remote| IO.copy_stream(remote, tmp) }
end
end
actual = Digest::SHA256.file(tmp).hexdigest
Expand Down Expand Up @@ -140,14 +140,14 @@ def download_parts(entry, tmp)
if part["url"].start_with?("file://")
File.open(source, "rb", &open_stream)
else
URI.open(source, "rb", &open_stream)
URI.parse(source).open("rb", &open_stream)
end
unless digest.hexdigest == part["sha256"]
raise RegistryError, "part #{index} of #{entry["filename"]} sha256 mismatch: got #{digest.hexdigest}, index says #{part["sha256"]}"
end
end
end
rescue StandardError
rescue
File.delete(tmp) if File.file?(tmp)
raise
end
Expand All @@ -167,10 +167,10 @@ def load_index(source)
# HTTP sources must ship a sibling .sha256 sidecar; verify the
# body against it before parsing (GitHub Releases index assets do).
def fetch_index_with_sidecar(source)
body = URI.open(source, "rb") { |remote| remote.read }
body = URI.parse(source).open("rb") { |remote| remote.read }
sidecar =
begin
URI.open("#{source}.sha256") { |remote| remote.read }
URI.parse("#{source}.sha256").open { |remote| remote.read }
rescue OpenURI::HTTPError => e
raise RegistryError, "index sha256 sidecar missing: #{source}.sha256 (#{e.message})"
end
Expand Down
26 changes: 13 additions & 13 deletions lib/interscript/ml/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,30 @@ class Model
def self.from_file(model_file)
# A models.yaml model id resolves (download -> verify -> cache)
# through the same contract as the Python/TS runtimes.
model_file = IMF.resolve(model_file) unless model_file.to_s.end_with?('.zip') || File.file?(model_file.to_s)
model_file = IMF.resolve(model_file) unless model_file.to_s.end_with?(".zip") || File.file?(model_file.to_s)
model_file = Provisioning.locate(model_file)

Zip::File.open(model_file) do |zip_file|
metadata = zip_file.glob('metadata.yaml').first
metadata = zip_file.glob("metadata.yaml").first
metadata = YAML.safe_load(metadata.get_input_stream.read) if metadata

# IMF v1: the Interscript Model Format zip.
return Byt5Onnx.new(model_file) if metadata && metadata['format'] == 'imf-v1'
return Byt5Onnx.new(model_file) if metadata && metadata["format"] == "imf-v1"

name = metadata && metadata['name']
name = metadata && metadata["name"]

# Modern byte-level seq2seq (ByT5 family): encoder.onnx + decoder.onnx.
return Byt5Onnx.new(model_file) if name == 'byt5'
return Byt5Onnx.new(model_file) if name == "byt5"

# Legacy single-file ONNX transformer zips (vocabs.yaml based).
vocabs = zip_file.glob('vocabs.yaml').first
raise 'vocabs.yaml is missing in model zip!' unless vocabs
vocabs = zip_file.glob("vocabs.yaml").first
raise "vocabs.yaml is missing in model zip!" unless vocabs
vocabs = YAML.safe_load(vocabs.get_input_stream.read)
input_vocab = Vocab.new(vocabs['input'], specials: [])
target_vocab = Vocab.new(vocabs['target'], specials: [])
input_vocab = Vocab.new(vocabs["input"], specials: [])
target_vocab = Vocab.new(vocabs["target"], specials: [])

onnx = zip_file.glob('*.onnx').first
raise 'onnx model file is missing in model zip!' unless onnx
onnx = zip_file.glob("*.onnx").first
raise "onnx model file is missing in model zip!" unless onnx
Onnx.new(onnx.get_input_stream.read, input_vocab, target_vocab)
end
end
Expand All @@ -41,11 +41,11 @@ def initialize(model_path_or_bytes, input_vocab, target_vocab)
end

def call(input, output, opts)
@model.predict({ src: input, tgt: output }.merge(opts))['output']
@model.predict({src: input, tgt: output}.merge(opts))["output"]
end

def argmax(*args)
self.call(*args).map { |i| i.flatten.each_with_index.max[1] }
call(*args).map { |i| i.flatten.each_with_index.max[1] }
end
end
end
Expand Down
51 changes: 27 additions & 24 deletions lib/interscript/ml/plane.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require 'tmpdir'
require 'json'
require "tmpdir"
require "json"

module Interscript
module ML
Expand All @@ -16,7 +16,7 @@ class PlaneModel
attr_reader :classes, :k_passes

def initialize(graph:, classes:, k_passes: 2)
Interscript::ML.require_optional!('onnxruntime')
Interscript::ML.require_optional!("onnxruntime")
@classes = classes.freeze
@n_classes = classes.length
@mask_id = @n_classes
Expand All @@ -27,8 +27,8 @@ def initialize(graph:, classes:, k_passes: 2)
@class_to_id = classes.each_with_index.to_h.freeze
# the onnxruntime gem loads from paths, not bytes (as byt5_onnx):
# verified bytes go to a tmpfile owned by this instance
@graph_tmpdir = Dir.mktmpdir('isx-plane')
path = File.join(@graph_tmpdir, 'plane.onnx')
@graph_tmpdir = Dir.mktmpdir("isx-plane")
path = File.join(@graph_tmpdir, "plane.onnx")
File.binwrite(path, graph)
@sess = OnnxRuntime::Model.new(path)
end
Expand Down Expand Up @@ -58,7 +58,7 @@ def split_planes(text)
else
close.call
skeleton << ch
classes << ''
classes << ""
end
end
close.call
Expand All @@ -75,18 +75,18 @@ def translate(text, preserve_diacritics: false)
ch = skeleton[i]
n = ch.bytesize
cls = if user_cls.empty?
votes = tok_preds[pos, n] || []
cid = votes.tally.max_by { |_, c| c }&.first || @n_classes
cid < @n_classes ? @classes[cid] : ''
else
user_cls # user diacritics round-trip byte-exactly
end
votes = tok_preds[pos, n] || []
cid = votes.tally.max_by { |_, c| c }&.first || @n_classes
(cid < @n_classes) ? @classes[cid] : ""
else
user_cls # user diacritics round-trip byte-exactly
end
pos += n
cls
end
out = +""
skeleton.each_char.with_index do |ch, i|
out << (ch == "\x00" ? final[i] : ch + final[i])
out << ((ch == "\x00") ? final[i] : ch + final[i])
end
out
end
Expand All @@ -102,7 +102,7 @@ def translate_plain(text)
votes = preds[pos, nbytes] || []
cid = votes.tally.max_by { |_, c| c }&.first || @n_classes
pos += nbytes
cid < @n_classes ? @classes[cid] : ''
(cid < @n_classes) ? @classes[cid] : ""
end
PlaneModel.render_plane(text, char_preds)
end
Expand All @@ -111,7 +111,7 @@ def k_pass_decode(ids, plane0)
plane = plane0
preds = nil
@k_passes.times do
logits = @sess.predict({ 'input_ids' => [ids], 'plane_ids' => [plane] })['class_logits'][0]
logits = @sess.predict({"input_ids" => [ids], "plane_ids" => [plane]})["class_logits"][0]
preds = logits.each_with_index.map { |row, i| row.index(row.max) }
plane = preds
end
Expand All @@ -129,11 +129,11 @@ def decode_pinned(skeleton, char_classes)
pid = @class_to_id.fetch(cls, @mask_id)
ch.bytesize.times { per_token_pin << pid }
end
plane0 = per_token_pin.map { |p| p == @mask_id ? @mask_id : p }
plane0 = per_token_pin.map { |p| (p == @mask_id) ? @mask_id : p }
k_pass_decode(ids, plane0)
end

def self.render_plane(skeleton, char_classes)
def self.render_plane(skeleton, char_classes) # rubocop:disable Lint/IneffectiveAccessModifier
skeleton.each_char.with_index.map do |ch, i|
ch + char_classes[i].to_s
end.join
Expand All @@ -142,14 +142,15 @@ def self.render_plane(skeleton, char_classes)
# The zip contract: metadata.yaml (kind=plane, k_passes, member
# sha256s) + plane.onnx + classes.json, every member sha-verified
# before the session is created.
# rubocop:disable Lint/IneffectiveAccessModifier
def self.from_zip(data)
require 'zip'
require "zip"
zf = Zip::File.open_buffer(data)
names = zf.entries.map(&:name)
meta = YAML.safe_load(zf.read('metadata.yaml'), permitted_classes: [], aliases: false)
raise IMF::FormatError, "plane zip: kind=#{meta['kind'].inspect}, expected 'plane'" if meta['kind'] != 'plane'
meta = YAML.safe_load(zf.read("metadata.yaml"), permitted_classes: [], aliases: false)
raise IMF::FormatError, "plane zip: kind=#{meta["kind"].inspect}, expected 'plane'" if meta["kind"] != "plane"

members = meta['members'] || {}
members = meta["members"] || {}
plane_graph = nil
classes_bytes = nil
%w[plane.onnx classes.json].each do |member|
Expand All @@ -161,18 +162,20 @@ def self.from_zip(data)
got = Digest::SHA256.hexdigest(bytes)
raise IMF::FormatError, "plane zip: #{member} sha256 mismatch (#{got})" if want != got

plane_graph = bytes if member == 'plane.onnx'
classes_bytes = bytes if member == 'classes.json'
plane_graph = bytes if member == "plane.onnx"
classes_bytes = bytes if member == "classes.json"
end
classes = JSON.parse(classes_bytes)
new(graph: plane_graph, classes: classes, k_passes: meta.fetch('k_passes', 2))
new(graph: plane_graph, classes: classes, k_passes: meta.fetch("k_passes", 2))
ensure
zf&.close
end
end

module_function

# rubocop:enable Lint/IneffectiveAccessModifier

def render_plane(skeleton, char_classes)
PlaneModel.render_plane(skeleton, char_classes)
end
Expand Down
Loading
Loading