From 997394fc5fac977d045049f0b222d23e06d380a7 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Wed, 21 Oct 2026 22:46:24 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(WO59/A3):=20Interscript::Equivalence?= =?UTF-8?q?=20=E2=80=94=20mechanical=20system-equivalence=20checking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probes a sample set through two systems and reports the agreement ratio plus the minimal distinguishing samples. Samples are caller-supplied or derived from the systems' own embedded tests (the natural probe set for near-equivalent authorities). The distinguishing corpus generator and the bounded-equivalence search build on this base. --- lib/interscript/equivalence.rb | 46 ++++++++++++++++++ spec/interscript/equivalence_spec.rb | 72 ++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 lib/interscript/equivalence.rb create mode 100644 spec/interscript/equivalence_spec.rb diff --git a/lib/interscript/equivalence.rb b/lib/interscript/equivalence.rb new file mode 100644 index 00000000..6fbcab68 --- /dev/null +++ b/lib/interscript/equivalence.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Interscript + # A3 (WO59): mechanical equivalence checking between two systems. + # + # Probes a sample set through both systems and reports the agreement + # ratio plus the minimal distinguishing samples. The sample set is + # caller-supplied or derived from the systems' own embedded tests. + class Equivalence + def self.check(first, second, samples:) + new(first, second).check(samples) + end + + # Embedded test inputs of the given systems (the natural probe set + # for near-equivalent authorities). + def self.samples_from_tests(*codes) + codes.flat_map do |code| + Array(Interscript.parse(code).tests&.data).map(&:first).compact + end.uniq + end + + def initialize(first, second) + @first = first + @second = second + end + + def check(samples) + distinguishing = [] + tested = 0 + matched = 0 + samples.each do |sample| + tested += 1 + a = Interscript.transliterate(@first, sample) + b = Interscript.transliterate(@second, sample) + if a == b + matched += 1 + else + distinguishing << { sample: sample, first: a, second: b } + end + end + ratio = tested.zero? ? 1.0 : matched.fdiv(tested) + { tested: tested, matched: matched, ratio: ratio, + distinguishing: distinguishing } + end + end +end diff --git a/spec/interscript/equivalence_spec.rb b/spec/interscript/equivalence_spec.rb new file mode 100644 index 00000000..6263c0ee --- /dev/null +++ b/spec/interscript/equivalence_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "json" +require "tmpdir" +require "interscript/equivalence" + +RSpec.describe Interscript::Equivalence do + def write_map(dir, name, body) + path = File.join(dir, "#{name}.isc") + File.write(path, body) + path + end + + def identical_pair(dir) + body = <<~ISC + system "TST:xx-Latn:Latn:1" { + metadata { + authority_id tst + id 1 + language iso-639-2:xx + source_script Latn + destination_script Latn + } + tests { "ab" -> "ab" } + stage main { parallel { sub "a" "a" } } + } + ISC + [write_map(dir, "tst-a-1", body), write_map(dir, "tst-b-1", body.gsub("tst-a-1", "tst-b-1"))] + end + + it "reports full agreement for identical systems" do + Dir.mktmpdir do |dir| + a, b = identical_pair(dir) + Interscript.load_path.unshift(dir) + begin + result = described_class.check("tst-a-1", "tst-b-1", samples: ["ab", "abc", "b"]) + expect(result[:ratio]).to eq(1.0) + expect(result[:distinguishing]).to be_empty + ensure + Interscript.load_path.delete(dir) + end + end + end + + it "finds the minimal distinguishing sample for divergent systems" do + Dir.mktmpdir do |dir| + body_a = <<~ISC + system "TST:xx-Latn:Latn:1" { + metadata { + authority_id tst + id 1 + language iso-639-2:xx + source_script Latn + destination_script Latn + } + stage main { parallel { sub "a" "x" } } + } + ISC + body_b = body_a.gsub('sub "a" "x"', 'sub "a" "y"').gsub("id 1", "id 2") + write_map(dir, "tst-c-1", body_a) + write_map(dir, "tst-c-2", body_b) + Interscript.load_path.unshift(dir) + begin + result = described_class.check("tst-c-1", "tst-c-2", samples: ["a", "b"]) + expect(result[:ratio]).to eq(0.5) + expect(result[:distinguishing]).to eq([{ sample: "a", first: "x", second: "y" }]) + ensure + Interscript.load_path.delete(dir) + end + end + end +end From 8d6d634f54a5cd73944ff7f691f10985a44a46b1 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Wed, 21 Oct 2026 23:09:53 +0800 Subject: [PATCH 2/5] test(WO80/A4): normative rulings for parallel composition Two rulings pinned as frozen vectors: a parallel block is single-pass longest-match; duplicate rules resolve last-declared-wins. Each A4 ruling lands this way -- empirical vectors first, the IS-1 v2 normative text accretes per ruled point. --- spec/isc/semantics_spec.rb | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 spec/isc/semantics_spec.rb diff --git a/spec/isc/semantics_spec.rb b/spec/isc/semantics_spec.rb new file mode 100644 index 00000000..fc01075d --- /dev/null +++ b/spec/isc/semantics_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# WO80/A4 — normative rulings for the ISC semantic surface. Each vector +# pins the reference behavior; IS-1 v2 documents these as normative +# sections (the spec text accretes one section per ruled point). + +require "tmpdir" + +RSpec.describe "ISC semantics — parallel composition" do + def with_map(name, body) + dir = Dir.mktmpdir + File.write(File.join(dir, name), body) + Interscript.load_path.unshift(dir) + yield + ensure + Interscript.load_path.delete(dir) if dir + end + + it "rules: a parallel block is single-pass longest-match" do + with_map("tst-long-1.isc", <<~ISC) do + system "TST:xx-Latn:Latn:1" { + metadata { + authority_id tst + id 1 + language iso-639-2:xx + source_script Latn + destination_script Latn + } + stage main { parallel { + sub "b" "Y" + sub "ab" "X" + } } + } + ISC + expect(Interscript.transliterate("tst-long-1", "ab")).to eq("X") + end + end + + it "rules: duplicate rules in a parallel resolve last-declared-wins" do + with_map("tst-tie-1.isc", <<~ISC) do + system "TST:xx-Latn:Latn:2" { + metadata { + authority_id tst + id 2 + language iso-639-2:xx + source_script Latn + destination_script Latn + } + stage main { parallel { + sub "a" "1" + sub "a" "2" + } } + } + ISC + expect(Interscript.transliterate("tst-tie-1", "a")).to eq("2") + end + end +end From e562ba2fd16d0d88a165457364d5a9a54e3772a8 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Wed, 21 Oct 2026 23:28:51 +0800 Subject: [PATCH 3/5] fix(WO79): scrub the byte-decode to match the python reference The byte-decode relabeled invalid sequences as UTF-8; the python reference decodes with errors=replace (U+FFFD). Ruby String#scrub matches byte-for-byte. Caught by the C1 conformance matrix: the invalid bytes crashed first_alternates' split. --- lib/interscript/ml/imf.rb | 5 ++++- spec/interscript/ml/imf_spec.rb | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/interscript/ml/imf.rb b/lib/interscript/ml/imf.rb index 3d56369d..bea06bf2 100644 --- a/lib/interscript/ml/imf.rb +++ b/lib/interscript/ml/imf.rb @@ -42,7 +42,10 @@ def decode(token_ids) next if token == PAD_ID || token == UNK_ID out << ((token - BYTE_OFFSET) % 256).chr end - out.force_encoding(Encoding::UTF_8) + # the python reference decodes with errors="replace": invalid + # bytes (mid-character sequence caps) become U+FFFD -- scrub to + # match byte-for-byte (the C1 matrix catch) + out.force_encoding(Encoding::UTF_8).scrub end def manifest(zip_path) diff --git a/spec/interscript/ml/imf_spec.rb b/spec/interscript/ml/imf_spec.rb index 58f4bc4c..bbd1e709 100644 --- a/spec/interscript/ml/imf_spec.rb +++ b/spec/interscript/ml/imf_spec.rb @@ -251,3 +251,12 @@ end end end + +RSpec.describe "IMF.decode byte hygiene" do + it "scrubs invalid UTF-8 produced by mid-character sequence caps" do + # 0xF0 0x9E begins a 4-byte UTF-8 sequence that never completes: the + # python reference decodes with errors="replace" (U+FFFD); the ruby + # runtime must match byte-for-byte (the C1 matrix catch) + expect(Interscript::ML::IMF.decode([100, 243, 161])).to eq("a�") + end +end From a043a79ec3b33f3b58e4a70d6eb1f58356bd9f9d Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Wed, 21 Oct 2026 23:40:32 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(WO79):=20align=20the=20decode=20cap=20w?= =?UTF-8?q?ith=20the=20contract=20=E2=80=94=20512=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONFORMANCE.md pins the decoder at a 512-step cap; the runtime defaults had drifted (translator 100, byt5 256) truncating long outputs (the fas docs-split rows). The C1 matrix caught it. --- lib/interscript/ml/byt5_onnx.rb | 2 +- lib/interscript/ml/translator.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/interscript/ml/byt5_onnx.rb b/lib/interscript/ml/byt5_onnx.rb index 375926c3..45d14fc6 100644 --- a/lib/interscript/ml/byt5_onnx.rb +++ b/lib/interscript/ml/byt5_onnx.rb @@ -18,7 +18,7 @@ def initialize(zip_path) @pasts = @kv ? zero_pasts : {} end - def translate(text, max_seq_length: 256) + def translate(text, max_seq_length: 512) ids = IMF.encode(text) return "" if ids.length == 1 # only the trailing EOS: empty input diff --git a/lib/interscript/ml/translator.rb b/lib/interscript/ml/translator.rb index 5adb352c..83e5d6b1 100644 --- a/lib/interscript/ml/translator.rb +++ b/lib/interscript/ml/translator.rb @@ -10,7 +10,7 @@ def initialize(model_file:) @model = Model.from_file(model_file) end - def translate(phrase, max_seq_length: 100) + def translate(phrase, max_seq_length: 512) return @model.translate(phrase, max_seq_length: max_seq_length) if @model.is_a?(Byt5Onnx) Interscript::ML.require_optional!("numo-narray", feature: "numo/narray") From a9fd582b8e7dfac9a11eff134e3b548e033f995e Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Thu, 22 Oct 2026 00:03:17 +0800 Subject: [PATCH 5/5] style(WO80): standardrb on the equivalence + conformance specs --- lib/interscript/equivalence.rb | 6 +++--- spec/interscript/equivalence_spec.rb | 4 ++-- spec/interscript/ml/conformance_spec.rb | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/interscript/equivalence.rb b/lib/interscript/equivalence.rb index 6fbcab68..efd18da9 100644 --- a/lib/interscript/equivalence.rb +++ b/lib/interscript/equivalence.rb @@ -35,12 +35,12 @@ def check(samples) if a == b matched += 1 else - distinguishing << { sample: sample, first: a, second: b } + distinguishing << {sample: sample, first: a, second: b} end end ratio = tested.zero? ? 1.0 : matched.fdiv(tested) - { tested: tested, matched: matched, ratio: ratio, - distinguishing: distinguishing } + {tested: tested, matched: matched, ratio: ratio, + distinguishing: distinguishing} end end end diff --git a/spec/interscript/equivalence_spec.rb b/spec/interscript/equivalence_spec.rb index 6263c0ee..6b59e0da 100644 --- a/spec/interscript/equivalence_spec.rb +++ b/spec/interscript/equivalence_spec.rb @@ -30,7 +30,7 @@ def identical_pair(dir) it "reports full agreement for identical systems" do Dir.mktmpdir do |dir| - a, b = identical_pair(dir) + identical_pair(dir) Interscript.load_path.unshift(dir) begin result = described_class.check("tst-a-1", "tst-b-1", samples: ["ab", "abc", "b"]) @@ -63,7 +63,7 @@ def identical_pair(dir) begin result = described_class.check("tst-c-1", "tst-c-2", samples: ["a", "b"]) expect(result[:ratio]).to eq(0.5) - expect(result[:distinguishing]).to eq([{ sample: "a", first: "x", second: "y" }]) + expect(result[:distinguishing]).to eq([{sample: "a", first: "x", second: "y"}]) ensure Interscript.load_path.delete(dir) end diff --git a/spec/interscript/ml/conformance_spec.rb b/spec/interscript/ml/conformance_spec.rb index ff144fbe..1d9ef728 100644 --- a/spec/interscript/ml/conformance_spec.rb +++ b/spec/interscript/ml/conformance_spec.rb @@ -22,7 +22,8 @@ failures << "#{v["src"].inspect} -> #{got.inspect} (expected #{v["out"].inspect})" if got != v["out"] end expect(failures).to be_empty, - "#{vectors['model']}: #{failures.length}/#{vectors['vectors'].length} mismatched\n" + - failures.first(3).join("\n") + "#{vectors["model"]}: #{failures.length}/#{vectors["vectors"].length} mismatched +" + + failures.first(3).join("\n") end end