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
3 changes: 2 additions & 1 deletion lib/interscript/ml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ module Interscript::ML
autoload :Model, "interscript/ml/model"
autoload :Vocab, "interscript/ml/vocab"
autoload :Byt5Onnx, "interscript/ml/byt5_onnx"
autoload :PlaneModel, "interscript/ml/plane"

# Expected to be loaded before IMF/Byt5Onnx entry points.
# Expected to be loaded before IMF/Byt5Onnx/PlaneModel entry points.
def self.require_optional!(gem_name, feature: gem_name)
require feature
rescue LoadError => e
Expand Down
98 changes: 98 additions & 0 deletions lib/interscript/ml/plane.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require 'tmpdir'
require 'json'

module Interscript
module ML
# Plane-factorized runtime (kind=plane artifacts): one ONNX graph
# maps (input_ids, plane_ids) -> per-position class logits over the
# diacritic classes; decoding is K mask-predict passes feeding the
# argmax plane back. Per-character class = majority vote of the
# char's byte-token predictions; render splices one mark after each
# base character. Token ids follow the canonical ByT5 table (byte+3,
# trailing EOS) — see Interscript::ML::IMF.
class PlaneModel
attr_reader :classes, :k_passes

def initialize(graph:, classes:, k_passes: 2)
Interscript::ML.require_optional!('onnxruntime')
@classes = classes.freeze
@n_classes = classes.length
@mask_id = @n_classes
@k_passes = k_passes
# 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')
File.binwrite(path, graph)
@sess = OnnxRuntime::Model.new(path)
end

def translate(text)
ids = IMF.encode(text)[0..-2] # strip EOS: one token per byte
n = ids.length
plane = Array.new(n, @mask_id)
preds = nil
@k_passes.times do
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

pos = 0
char_preds = text.each_char.map do |ch|
nbytes = ch.bytesize
votes = preds[pos, nbytes] || []
cid = votes.tally.max_by { |_, c| c }&.first || @n_classes
pos += nbytes
cid < @n_classes ? @classes[cid] : ''
end
PlaneModel.render_plane(text, char_preds)
end

def self.render_plane(skeleton, char_classes)
skeleton.each_char.with_index.map do |ch, i|
ch + char_classes[i].to_s
end.join
end

# The zip contract: metadata.yaml (kind=plane, k_passes, member
# sha256s) + plane.onnx + classes.json, every member sha-verified
# before the session is created.
def self.from_zip(data)
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'

members = meta['members'] || {}
plane_graph = nil
classes_bytes = nil
%w[plane.onnx classes.json].each do |member|
raise IMF::FormatError, "plane zip: #{member} missing" unless names.include?(member)

bytes = zf.read(member)
want = members[member]
raise IMF::FormatError, "plane zip: #{member} has no sha256 in metadata" if want.nil?
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'
end
classes = JSON.parse(classes_bytes)
new(graph: plane_graph, classes: classes, k_passes: meta.fetch('k_passes', 2))
ensure
zf&.close
end
end

module_function

def render_plane(skeleton, char_classes)
PlaneModel.render_plane(skeleton, char_classes)
end
end
end
Binary file added spec/interscript/fixtures/tiny-plane.zip
Binary file not shown.
48 changes: 48 additions & 0 deletions spec/interscript/ml/golden_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

# Neural cross-runtime parity: run a real artifact against the shared
# golden set (interscript-models/golden/*.jsonl) and require
# byte-identical output. Gated behind SECRYST_E2E_ZIP + SECRYST_GOLDEN
# (same contract as the Python and TypeScript runners).

RSpec.describe Interscript::ML::IMF do
# Quantized (int8) graphs are NOT byte-portable across onnxruntime
# builds: near-tied logits can flip argmax. Measured on
# ara-diac-plane-1.0 (py reference vs this runtime): 3/50 rows with
# 1-2 char diffs over 600-1000-char rows - numeric, not logic. The
# parity contract for quantized models is therefore per-row char
# agreement >= 99.5% and corpus agreement >= 99.9%. Deterministic
# graphs (fp32 fixtures, maps) remain byte-exact via the plain path.
describe "golden set e2e (cross-runtime parity)" do
it "matches the reference within the quantized tolerance (or byte-exactly for fp32)" do
zip = ENV["SECRYST_E2E_ZIP"]
golden = ENV["SECRYST_GOLDEN"]
skip "set SECRYST_E2E_ZIP and SECRYST_GOLDEN" if zip.nil? || golden.nil?

plane = ENV["SECRYST_E2E_PLANE"] == "1"
require "json"
rows = File.readlines(golden, chomp: true).reject(&:empty?).map { |l| JSON.parse(l) }
model =
if plane
Interscript::ML::PlaneModel.from_zip(File.binread(zip))
else
Interscript::ML::Translator.new(model_file: zip)
end

total_chars = 0
total_diffs = 0
rows.each do |row|
out = plane ? model.translate(row["input"]) : model.translate(row["input"], max_seq_length: 128)
total_chars += row["output"].length
next if out == row["output"]

exp = row["output"].chars
got = out.chars
d = (0...[exp.length, got.length].min).count { |j| exp[j] != got[j] }
total_diffs += d
expect(d.to_f / exp.length).to be < 0.005, row["input"][0, 60]
end
expect(total_diffs.to_f / total_chars).to be < 0.001
end
end
end
52 changes: 52 additions & 0 deletions spec/interscript/ml/plane_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

# Plane runtime (kind=plane artifacts): one ONNX graph over
# (input_ids, plane_ids), K mask-predict passes, per-char majority vote,
# byte-exact render. Fixture graph cycles classes so the K-pass loop is
# observable: class(plane) = (plane + 1) % n_classes.

RSpec.describe Interscript::ML::PlaneModel do
let(:zip) { File.expand_path("../fixtures/tiny-plane.zip", __dir__) }

describe ".from_zip" do
it "verifies member sha256s and rejects tampering" do
expect { described_class.from_zip(File.read(zip)) }.not_to raise_error
require 'zip'
require 'stringio'
src = Zip::File.open(zip)
bad = Zip::File.open_buffer(StringIO.new(File.binread(zip)))
bad_entry = bad.entries.find { |e| e.name == 'plane.onnx' }
tampered = bad_entry.get_input_stream.read + "x"
buf = Zip::OutputStream.write_buffer do |out|
src.entries.each do |e|
out.put_next_entry(e.name)
out.write(e.name == 'plane.onnx' ? tampered : e.get_input_stream.read)
end
end.string
expect { described_class.from_zip(buf) }.to raise_error(Interscript::ML::IMF::FormatError, /sha256 mismatch/)
end
end

describe "#translate" do
it "runs the K-pass loop and renders one class per char" do
m = described_class.from_zip(File.read(zip))
expect(m.k_passes).to eq(2)
# MASK(4) -> 1 -> 2: with k=2 every position lands on damma
expect(m.translate("كتب")).to eq("كُتُبُ")
end

it "keeps the skeleton byte-exact (round-trip)" do
m = described_class.from_zip(File.read(zip))
text = "الرجل ثائر في البيت الكبير"
out = m.translate(text)
plain = out.chars.reject { |c| m.classes.include?(c) }.join
expect(plain).to eq(text)
end
end

describe ".render_plane" do
it "renders classes onto the skeleton" do
expect(Interscript::ML.render_plane("كتب", ["", "َ", "ُ"])).to eq("كتَبُ")
end
end
end
Loading