Skip to content

Commit 33d2180

Browse files
authored
feat: plane runtime preserve_diacritics mode — user diacritics pinned in decode, byte-exact round-trip (#801)
1 parent 0d12abc commit 33d2180

2 files changed

Lines changed: 125 additions & 9 deletions

File tree

‎lib/interscript/ml/plane.rb‎

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ def initialize(graph:, classes:, k_passes: 2)
2121
@n_classes = classes.length
2222
@mask_id = @n_classes
2323
@k_passes = k_passes
24+
# a character is a diacritic iff it occurs inside some class the
25+
# artifact knows — no per-language mark tables needed
26+
@mark_chars = classes.flat_map { |c| c.chars }.reject(&:empty?).uniq.freeze
27+
@class_to_id = classes.each_with_index.to_h.freeze
2428
# the onnxruntime gem loads from paths, not bytes (as byt5_onnx):
2529
# verified bytes go to a tmpfile owned by this instance
2630
@graph_tmpdir = Dir.mktmpdir('isx-plane')
@@ -29,17 +33,69 @@ def initialize(graph:, classes:, k_passes: 2)
2933
@sess = OnnxRuntime::Model.new(path)
3034
end
3135

32-
def translate(text)
33-
ids = IMF.encode(text)[0..-2] # strip EOS: one token per byte
34-
n = ids.length
35-
plane = Array.new(n, @mask_id)
36-
preds = nil
37-
@k_passes.times do
38-
logits = @sess.predict({ 'input_ids' => [ids], 'plane_ids' => [plane] })['class_logits'][0]
39-
preds = logits.each_with_index.map { |row, i| row.index(row.max) }
40-
plane = preds
36+
# Split text into (skeleton, per-base classes). Marks FOLLOW their
37+
# base letter; leading marks bind to a "\x00" anchor (render drops
38+
# it). Cluster order is preserved as written — render-exactness
39+
# beats normalization (the Hebrew canon lesson).
40+
def split_planes(text)
41+
skeleton = []
42+
classes = []
43+
current = []
44+
close = lambda do
45+
next if current.empty?
46+
47+
if skeleton.empty?
48+
skeleton << "\x00"
49+
classes << current.join
50+
else
51+
classes[-1] = classes[-1] + current.join
52+
end
53+
current = []
54+
end
55+
text.each_char do |ch|
56+
if @mark_chars.include?(ch)
57+
current << ch
58+
else
59+
close.call
60+
skeleton << ch
61+
classes << ''
62+
end
63+
end
64+
close.call
65+
[skeleton.join, classes]
66+
end
67+
68+
def translate(text, preserve_diacritics: false)
69+
return translate_plain(text) unless preserve_diacritics
70+
71+
skeleton, char_classes = split_planes(text)
72+
tok_preds = decode_pinned(skeleton, char_classes)
73+
pos = 0
74+
final = char_classes.each_with_index.map do |user_cls, i|
75+
ch = skeleton[i]
76+
n = ch.bytesize
77+
cls = if user_cls.empty?
78+
votes = tok_preds[pos, n] || []
79+
cid = votes.tally.max_by { |_, c| c }&.first || @n_classes
80+
cid < @n_classes ? @classes[cid] : ''
81+
else
82+
user_cls # user diacritics round-trip byte-exactly
83+
end
84+
pos += n
85+
cls
86+
end
87+
out = +""
88+
skeleton.each_char.with_index do |ch, i|
89+
out << (ch == "\x00" ? final[i] : ch + final[i])
4190
end
91+
out
92+
end
93+
94+
private
4295

96+
def translate_plain(text)
97+
ids = IMF.encode(text)[0..-2] # strip EOS: one token per byte
98+
preds = k_pass_decode(ids, Array.new(ids.length, @mask_id))
4399
pos = 0
44100
char_preds = text.each_char.map do |ch|
45101
nbytes = ch.bytesize
@@ -51,6 +107,32 @@ def translate(text)
51107
PlaneModel.render_plane(text, char_preds)
52108
end
53109

110+
def k_pass_decode(ids, plane0)
111+
plane = plane0
112+
preds = nil
113+
@k_passes.times do
114+
logits = @sess.predict({ 'input_ids' => [ids], 'plane_ids' => [plane] })['class_logits'][0]
115+
preds = logits.each_with_index.map { |row, i| row.index(row.max) }
116+
plane = preds
117+
end
118+
preds
119+
end
120+
121+
# K-pass decode with user classes pinned: known classes are
122+
# conditioned AND never overwritten; unknown non-empty clusters
123+
# stay masked during decode but are restored at render.
124+
def decode_pinned(skeleton, char_classes)
125+
ids = IMF.encode(skeleton)[0..-2]
126+
per_token_pin = []
127+
skeleton.each_char.with_index do |ch, i|
128+
cls = char_classes[i]
129+
pid = @class_to_id.fetch(cls, @mask_id)
130+
ch.bytesize.times { per_token_pin << pid }
131+
end
132+
plane0 = per_token_pin.map { |p| p == @mask_id ? @mask_id : p }
133+
k_pass_decode(ids, plane0)
134+
end
135+
54136
def self.render_plane(skeleton, char_classes)
55137
skeleton.each_char.with_index.map do |ch, i|
56138
ch + char_classes[i].to_s

‎spec/interscript/ml/plane_spec.rb‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,37 @@
5050
end
5151
end
5252
end
53+
54+
RSpec.describe Interscript::ML::PlaneModel, "#translate preserve_diacritics" do
55+
let(:zip) { File.expand_path("../fixtures/tiny-plane.zip", __dir__) }
56+
let(:m) { described_class.from_zip(File.read(zip)) }
57+
let(:plain_of) { ->(s) { s.chars.reject { |c| m.classes.include?(c) }.join } }
58+
59+
it "default path unchanged by flag" do
60+
expect(m.translate("كتب")).to eq(m.translate("كتب", preserve_diacritics: false))
61+
end
62+
63+
it "fully labeled input round-trips byte-exactly" do
64+
labeled = "كُتُبُ"
65+
expect(m.translate(labeled, preserve_diacritics: true)).to eq(labeled)
66+
end
67+
68+
it "partial input keeps user classes and fills the rest" do
69+
out = m.translate("كُتب", preserve_diacritics: true)
70+
expect(out).to start_with("كُ")
71+
expect(plain_of.call(out)).to eq("كتب")
72+
end
73+
74+
it "leading marks round-trip without anchors leaking" do
75+
out = m.translate("ُكتب", preserve_diacritics: true)
76+
expect(out[0]).to eq("ُ")
77+
expect(out).not_to include("\x00")
78+
expect(plain_of.call(out)).to eq("كتب")
79+
end
80+
81+
it "unknown cluster still round-trips byte-exactly" do
82+
out = m.translate("كُْتب", preserve_diacritics: true)
83+
expect(out).to include("كُْ")
84+
expect(plain_of.call(out)).to eq("كتب")
85+
end
86+
end

0 commit comments

Comments
 (0)