secryst 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.adoc +407 -15
- data/bin/secryst +42 -0
- data/lib/secryst/byt5_onnx.rb +103 -0
- data/lib/secryst/imf.rb +155 -0
- data/lib/secryst/model.rb +52 -0
- data/lib/secryst/multi_head_attention_forward.rb +2 -2
- data/lib/secryst/provisioning.rb +189 -0
- data/lib/secryst/translator.rb +21 -35
- data/lib/secryst/version.rb +2 -2
- data/lib/secryst/vocab.rb +14 -54
- data/lib/secryst.rb +20 -9
- data/spec/fixtures/tiny-imf.zip +0 -0
- data/spec/parity_spec.rb +33 -0
- data/spec/secryst/byt5_onnx_spec.rb +17 -0
- data/spec/secryst/imf_spec.rb +180 -0
- data/spec/spec_helper.rb +3 -0
- metadata +72 -17
- data/lib/secryst/clip_grad_norm.rb +0 -25
- data/lib/secryst/multihead_attention.rb +0 -156
- data/lib/secryst/trainer.rb +0 -235
- data/lib/secryst/transformer.rb +0 -382
- data/lib/secryst-trainer.rb +0 -8
data/lib/secryst/imf.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "zip"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "digest/sha2"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open-uri"
|
|
6
|
+
|
|
7
|
+
module Secryst
|
|
8
|
+
# Interscript Model Format v1 — the byte-level runtime contract shared
|
|
9
|
+
# with the Python (interscript-ml) and TypeScript (npm: secryst)
|
|
10
|
+
# runtimes. Token ids follow the canonical ByT5 table: byte b -> b+3,
|
|
11
|
+
# trailing EOS; pad=0, unk=2. Ids are NOT raw byte values.
|
|
12
|
+
module IMF
|
|
13
|
+
BYTE_OFFSET = 3
|
|
14
|
+
PAD_ID = 0
|
|
15
|
+
EOS_ID = 1
|
|
16
|
+
UNK_ID = 2
|
|
17
|
+
|
|
18
|
+
DEFAULT_INDEX_URL = "https://raw.githubusercontent.com/interscript/interscript-ml/main/models.yaml"
|
|
19
|
+
|
|
20
|
+
class FormatError < StandardError; end
|
|
21
|
+
class RegistryError < StandardError; end
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def encode(text)
|
|
25
|
+
text.bytes.map { |b| b + BYTE_OFFSET } + [EOS_ID]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def decode(token_ids)
|
|
29
|
+
out = +""
|
|
30
|
+
token_ids.each do |token|
|
|
31
|
+
break if token == EOS_ID
|
|
32
|
+
next if token == PAD_ID || token == UNK_ID
|
|
33
|
+
out << ((token - BYTE_OFFSET) % 256).chr
|
|
34
|
+
end
|
|
35
|
+
out.force_encoding(Encoding::UTF_8)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def manifest(zip_path)
|
|
39
|
+
Zip::File.open(zip_path) do |zf|
|
|
40
|
+
raise FormatError, "missing metadata.yaml" unless zf.find_entry("metadata.yaml")
|
|
41
|
+
meta = YAML.safe_load(zf.read("metadata.yaml"), permitted_classes: [], aliases: false)
|
|
42
|
+
raise FormatError, "unsupported format: #{meta["format"].inspect}" if meta["format"] != "imf-v1"
|
|
43
|
+
if meta["tokenizer"] != "bytes"
|
|
44
|
+
raise FormatError, "tokenizer #{meta["tokenizer"].inspect}: this runtime is byte-level only"
|
|
45
|
+
end
|
|
46
|
+
%w[encoder.onnx decoder.onnx].each do |required|
|
|
47
|
+
raise FormatError, "missing #{required}" unless zf.find_entry(required)
|
|
48
|
+
end
|
|
49
|
+
meta
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Reads every .onnx member after verifying its sha256 against the
|
|
54
|
+
# manifest — corrupt zips fail loudly, before any session loads.
|
|
55
|
+
def verify_and_read(zip_path)
|
|
56
|
+
meta = manifest(zip_path)
|
|
57
|
+
sha = meta.fetch("sha256", {})
|
|
58
|
+
graphs = {}
|
|
59
|
+
Zip::File.open(zip_path) do |zf|
|
|
60
|
+
zf.entries.select { |e| e.name.end_with?(".onnx") }.each do |entry|
|
|
61
|
+
recorded = sha[entry.name]
|
|
62
|
+
raise FormatError, "#{entry.name} is not covered by metadata sha256" unless recorded
|
|
63
|
+
bytes = entry.get_input_stream.read
|
|
64
|
+
actual = Digest::SHA256.hexdigest(bytes)
|
|
65
|
+
if actual != recorded
|
|
66
|
+
raise FormatError, "#{entry.name} sha256 mismatch: zip has #{actual}, metadata says #{recorded}"
|
|
67
|
+
end
|
|
68
|
+
graphs[entry.name] = bytes
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
graphs
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cache_dir
|
|
75
|
+
ENV["SECRYST_CACHE"] || File.join(Dir.home, ".cache", "secryst")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# models.yaml resolution: cache hit (re-verified), or download ->
|
|
79
|
+
# verify whole-file sha256 -> atomic install into the cache.
|
|
80
|
+
# Entries with `parts` (GitHub's 2 GiB per-asset cap) are streamed
|
|
81
|
+
# in order, each part sha256-verified as it lands, then the
|
|
82
|
+
# assembled file is checked against the whole-file sha256.
|
|
83
|
+
def resolve(model_id, index_url: nil)
|
|
84
|
+
source = index_url || ENV["SECRYST_INDEX"] || DEFAULT_INDEX_URL
|
|
85
|
+
entries = load_index(source)
|
|
86
|
+
entry = entries[model_id]
|
|
87
|
+
raise RegistryError, "unknown model id #{model_id.inspect} (known: #{entries.keys.sort})" unless entry
|
|
88
|
+
|
|
89
|
+
target = File.join(cache_dir, "models", model_id, entry["filename"])
|
|
90
|
+
if File.file?(target) && Digest::SHA256.file(target).hexdigest == entry["sha256"]
|
|
91
|
+
return target
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
95
|
+
tmp = target + ".part.#{Process.pid}"
|
|
96
|
+
if entry["parts"]
|
|
97
|
+
download_parts(entry, tmp)
|
|
98
|
+
else
|
|
99
|
+
channel = entry["url"]
|
|
100
|
+
if channel.start_with?("file://")
|
|
101
|
+
FileUtils.cp(channel.sub(%r{\Afile://}, ""), tmp)
|
|
102
|
+
else
|
|
103
|
+
URI.open(channel) { |remote| IO.copy_stream(remote, tmp) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
actual = Digest::SHA256.file(tmp).hexdigest
|
|
107
|
+
unless actual == entry["sha256"]
|
|
108
|
+
File.delete(tmp)
|
|
109
|
+
raise RegistryError, "downloaded #{entry["filename"]} sha256 mismatch: got #{actual}, index says #{entry["sha256"]}"
|
|
110
|
+
end
|
|
111
|
+
File.rename(tmp, target)
|
|
112
|
+
target
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def download_parts(entry, tmp)
|
|
118
|
+
File.open(tmp, "wb") do |out|
|
|
119
|
+
entry["parts"].each_with_index do |part, index|
|
|
120
|
+
digest = Digest::SHA256.new
|
|
121
|
+
source = part["url"].start_with?("file://") ? part["url"].sub(%r{\Afile://}, "") : part["url"]
|
|
122
|
+
open_stream = lambda do |io|
|
|
123
|
+
while (chunk = io.read(1024 * 1024))
|
|
124
|
+
out.write(chunk)
|
|
125
|
+
digest.update(chunk)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
if part["url"].start_with?("file://")
|
|
129
|
+
File.open(source, "rb", &open_stream)
|
|
130
|
+
else
|
|
131
|
+
URI.open(source, "rb", &open_stream)
|
|
132
|
+
end
|
|
133
|
+
unless digest.hexdigest == part["sha256"]
|
|
134
|
+
raise RegistryError, "part #{index} of #{entry["filename"]} sha256 mismatch: got #{digest.hexdigest}, index says #{part["sha256"]}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
rescue StandardError
|
|
139
|
+
File.delete(tmp) if File.file?(tmp)
|
|
140
|
+
raise
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def load_index(source)
|
|
144
|
+
text = if source.start_with?("http://", "https://")
|
|
145
|
+
URI.open(source) { |remote| remote.read }
|
|
146
|
+
else
|
|
147
|
+
File.read(source)
|
|
148
|
+
end
|
|
149
|
+
raw = YAML.safe_load(text, permitted_classes: [], aliases: false)
|
|
150
|
+
raise RegistryError, "index must have version: 1" if raw["version"] != 1
|
|
151
|
+
raw.fetch("models", {})
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Secryst
|
|
2
|
+
class Model
|
|
3
|
+
attr_accessor :model, :input_vocab, :target_vocab
|
|
4
|
+
|
|
5
|
+
def self.from_file(model_file)
|
|
6
|
+
# A models.yaml model id resolves (download -> verify -> cache)
|
|
7
|
+
# through the same contract as the Python/TS runtimes.
|
|
8
|
+
model_file = IMF.resolve(model_file) unless model_file.to_s.end_with?('.zip') || File.file?(model_file.to_s)
|
|
9
|
+
model_file = Provisioning.locate(model_file)
|
|
10
|
+
|
|
11
|
+
Zip::File.open(model_file) do |zip_file|
|
|
12
|
+
metadata = zip_file.glob('metadata.yaml').first
|
|
13
|
+
metadata = YAML.safe_load(metadata.get_input_stream.read) if metadata
|
|
14
|
+
|
|
15
|
+
# IMF v1: the Interscript Model Format zip.
|
|
16
|
+
return Byt5Onnx.new(model_file) if metadata && metadata['format'] == 'imf-v1'
|
|
17
|
+
|
|
18
|
+
name = metadata && metadata['name']
|
|
19
|
+
|
|
20
|
+
# Modern byte-level seq2seq (ByT5 family): encoder.onnx + decoder.onnx.
|
|
21
|
+
return Byt5Onnx.new(model_file) if name == 'byt5'
|
|
22
|
+
|
|
23
|
+
# Legacy single-file ONNX transformer zips (vocabs.yaml based).
|
|
24
|
+
vocabs = zip_file.glob('vocabs.yaml').first
|
|
25
|
+
raise 'vocabs.yaml is missing in model zip!' unless vocabs
|
|
26
|
+
vocabs = YAML.safe_load(vocabs.get_input_stream.read)
|
|
27
|
+
input_vocab = Vocab.new(vocabs['input'], specials: [])
|
|
28
|
+
target_vocab = Vocab.new(vocabs['target'], specials: [])
|
|
29
|
+
|
|
30
|
+
onnx = zip_file.glob('*.onnx').first
|
|
31
|
+
raise 'onnx model file is missing in model zip!' unless onnx
|
|
32
|
+
Onnx.new(onnx.get_input_stream.read, input_vocab, target_vocab)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class Onnx < Model
|
|
37
|
+
def initialize(model_path_or_bytes, input_vocab, target_vocab)
|
|
38
|
+
@model = OnnxRuntime::Model.new(model_path_or_bytes)
|
|
39
|
+
@input_vocab = input_vocab
|
|
40
|
+
@target_vocab = target_vocab
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(input, output, opts)
|
|
44
|
+
@model.predict({ src: input, tgt: output }.merge(opts))['output']
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def argmax(*args)
|
|
48
|
+
self.call(*args).map { |i| i.flatten.each_with_index.max[1] }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -175,7 +175,7 @@ module Secryst
|
|
|
175
175
|
if attn_mask
|
|
176
176
|
raise ArgumentError, 'Only float, byte, and bool types are supported for attn_mask, not %s' % attn_mask.dtype unless attn_mask.dtype == Torch.float32 || attn_mask.dtype == Torch.float64 || attn_mask.dtype == Torch.float16 || attn_mask.dtype == Torch.uint8 || attn_mask.dtype == Torch.bool
|
|
177
177
|
if attn_mask.dtype == Torch.uint8
|
|
178
|
-
puts "Byte tensor for attn_mask in NN::MultiheadAttention is deprecated. Use bool tensor instead."
|
|
178
|
+
# puts "Byte tensor for attn_mask in NN::MultiheadAttention is deprecated. Use bool tensor instead."
|
|
179
179
|
attn_mask = attn_mask.to(Torch.bool)
|
|
180
180
|
end
|
|
181
181
|
|
|
@@ -192,7 +192,7 @@ module Secryst
|
|
|
192
192
|
|
|
193
193
|
# convert ByteTensor key_padding_mask to bool
|
|
194
194
|
if key_padding_mask && key_padding_mask.dtype == Torch.uint8
|
|
195
|
-
puts("Byte tensor for key_padding_mask in NN::MultiheadAttention is deprecated. Use bool tensor instead.")
|
|
195
|
+
# puts("Byte tensor for key_padding_mask in NN::MultiheadAttention is deprecated. Use bool tensor instead.")
|
|
196
196
|
key_padding_mask = key_padding_mask.to(Torch.bool)
|
|
197
197
|
end
|
|
198
198
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
require 'open-uri'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'digest/sha2'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module Secryst
|
|
8
|
+
# Module Secryst::Provisioning is to provision remote models locally and to
|
|
9
|
+
# dispatch them later on.
|
|
10
|
+
module Provisioning
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
@remotes = [] # Here's a place for a global model repository
|
|
14
|
+
@preload_models = []
|
|
15
|
+
attr_accessor :remotes, :preload_models
|
|
16
|
+
|
|
17
|
+
def add_remote(path)
|
|
18
|
+
@remotes << path
|
|
19
|
+
@remotes = @remotes.uniq
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def prepare_environment
|
|
23
|
+
return if @set_up
|
|
24
|
+
|
|
25
|
+
# We provision the environment in the following way:
|
|
26
|
+
# First, we try the SECRYST_DATA environment variable. If that's available,
|
|
27
|
+
# we use it to store the Secryst data we need. Otherwise, we try the following
|
|
28
|
+
# paths:
|
|
29
|
+
|
|
30
|
+
possible_paths = [
|
|
31
|
+
"/var/lib/secryst",
|
|
32
|
+
"/usr/local/share/secryst",
|
|
33
|
+
"/usr/share/secryst",
|
|
34
|
+
File.join(Dir.home, ".local/share/secryst")
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# We find the first writable path to become the primary one. The remaining
|
|
38
|
+
# ones will be used read-only if they exist
|
|
39
|
+
|
|
40
|
+
@write_path = nil
|
|
41
|
+
@read_paths = []
|
|
42
|
+
|
|
43
|
+
([ENV["SECRYST_DATA"]] + possible_paths).compact.each do |path|
|
|
44
|
+
FileUtils.mkdir_p(path)
|
|
45
|
+
@write_path = path unless @write_path
|
|
46
|
+
rescue
|
|
47
|
+
ensure
|
|
48
|
+
@read_paths << path if File.readable?(path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise StandardError, "Can't find a writable path for Secryst. Consider setting a SECRYST_DATA environment variable" unless @write_path
|
|
52
|
+
|
|
53
|
+
# Now, let's locate the first Secrystfile to be found
|
|
54
|
+
path = Dir.pwd
|
|
55
|
+
secrystfilepath = loop do
|
|
56
|
+
break unless path =~ %r{[/\\]}
|
|
57
|
+
if File.readable?(path + "/Secrystfile")
|
|
58
|
+
break path + "/Secrystfile"
|
|
59
|
+
end
|
|
60
|
+
path = path.sub(%r{[/\\][^/\\]*?\z}, '')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# It's found, so let's parse it.
|
|
64
|
+
if secrystfilepath
|
|
65
|
+
secrystfile = Secrystfile.new(secrystfilepath)
|
|
66
|
+
|
|
67
|
+
@remotes = secrystfile.remotes + @remotes
|
|
68
|
+
@remotes = @remotes.uniq
|
|
69
|
+
|
|
70
|
+
@preload_models = secrystfile.models
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Load the remotes if they are older than 1 minute
|
|
74
|
+
FileUtils.mkdir_p(@write_path + "/remotes/")
|
|
75
|
+
@loaded_remotes = @remotes.map do |uri|
|
|
76
|
+
cache_path = "#{@write_path}/remotes/#{Digest::SHA256.hexdigest(uri)}.yaml"
|
|
77
|
+
if !File.exist?(cache_path)
|
|
78
|
+
data = URI.open(uri).read
|
|
79
|
+
File.write(cache_path, data)
|
|
80
|
+
elsif File.mtime(cache_path) + 60 < Time.now
|
|
81
|
+
begin
|
|
82
|
+
# Just *try* to download it.
|
|
83
|
+
data = URI.open(uri).read
|
|
84
|
+
File.write(cache_path, data)
|
|
85
|
+
rescue
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
Remotefile.new(cache_path, uri: uri)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Ok we are done now. We still need to resolve the required paths, but for
|
|
92
|
+
# that let's reuse our existing facilities.
|
|
93
|
+
@set_up = true
|
|
94
|
+
|
|
95
|
+
@preload_models.each { |i| locate(i) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def locate(name)
|
|
99
|
+
# Shortcut this if user gave a filename.
|
|
100
|
+
return name if name =~ %r{[.\\/]}
|
|
101
|
+
|
|
102
|
+
prepare_environment
|
|
103
|
+
|
|
104
|
+
@loaded_remotes.each do |i|
|
|
105
|
+
model = i.resolve(name)
|
|
106
|
+
if model
|
|
107
|
+
model_path = @read_paths.map do |j|
|
|
108
|
+
path = j + "/models/" + model.name
|
|
109
|
+
next path if File.readable?(path)
|
|
110
|
+
nil
|
|
111
|
+
end.compact.first
|
|
112
|
+
|
|
113
|
+
if model_path
|
|
114
|
+
version = File.read(model_path + "/version").to_f
|
|
115
|
+
if version >= model.version
|
|
116
|
+
return model_path + "/model.zip"
|
|
117
|
+
else
|
|
118
|
+
return download(model, remote: i)
|
|
119
|
+
end
|
|
120
|
+
else
|
|
121
|
+
return download(model, remote: i)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
raise StandardError, "Model #{name} not found"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def download(model, remote:)
|
|
130
|
+
uri = model.uri
|
|
131
|
+
if uri.start_with?("./") || uri.start_with?("../")
|
|
132
|
+
remote_uri = remote.uri
|
|
133
|
+
if remote_uri =~ %r{\A/|\A\w:[\\/]}
|
|
134
|
+
remote_uri = "file://"+remote_uri
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
uri = URI(remote_uri).merge(uri).to_s
|
|
138
|
+
uri = uri.sub(%r{\Afile://}, '')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
warn "* Downloading a Secryst model #{model.name}:#{model.version} from #{uri}..."
|
|
142
|
+
|
|
143
|
+
data = URI.open(uri).read
|
|
144
|
+
path = @write_path + "/models/" + model.name
|
|
145
|
+
FileUtils.mkdir_p path
|
|
146
|
+
File.write(path + "/version", model.version)
|
|
147
|
+
File.write(path + "/model.zip", data)
|
|
148
|
+
return path + "/model.zip"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class Remotefile
|
|
152
|
+
attr_accessor :uri
|
|
153
|
+
|
|
154
|
+
def initialize(path, uri:)
|
|
155
|
+
@yaml = YAML.load_file(path)
|
|
156
|
+
@uri = uri
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def resolve(name)
|
|
160
|
+
@yaml["models"].find do |n, desc|
|
|
161
|
+
if name == n
|
|
162
|
+
return Model.new(name: n, uri: desc["path"], version: desc["version"])
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
class Model < Struct.new(:name, :uri, :version, keyword_init: true)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
class Secrystfile
|
|
172
|
+
def initialize(path)
|
|
173
|
+
@path = path
|
|
174
|
+
@models, @remotes = [], []
|
|
175
|
+
self.instance_eval(File.read(path), path)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def model(name)
|
|
179
|
+
@models << name
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def source(src)
|
|
183
|
+
@remotes << src
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
attr_accessor :models, :remotes
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
data/lib/secryst/translator.rb
CHANGED
|
@@ -1,51 +1,37 @@
|
|
|
1
1
|
module Secryst
|
|
2
|
+
# Dispatching translator: byte-level ByT5 models (name: byt5 in
|
|
3
|
+
# metadata.yaml) translate themselves; legacy char-vocab ONNX zips use
|
|
4
|
+
# the original greedy loop.
|
|
2
5
|
class Translator
|
|
3
|
-
|
|
4
|
-
@device = "cpu"
|
|
5
|
-
@vocabs_dir = vocabs_dir
|
|
6
|
+
attr_accessor :model
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@model = Secryst::Transformer.new(hyperparameters.merge({
|
|
11
|
-
input_vocab_size: @input_vocab.length,
|
|
12
|
-
target_vocab_size: @target_vocab.length,
|
|
13
|
-
}))
|
|
14
|
-
else
|
|
15
|
-
raise ArgumentError, 'Only transformer model is currently supported'
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
@model.load_state_dict(Torch.load(model_file))
|
|
19
|
-
@model.eval
|
|
8
|
+
def initialize(model_file:)
|
|
9
|
+
@device = 'cpu'
|
|
10
|
+
@model = Model.from_file(model_file)
|
|
20
11
|
end
|
|
21
12
|
|
|
22
13
|
def translate(phrase, max_seq_length: 100)
|
|
14
|
+
return @model.translate(phrase, max_seq_length: max_seq_length) if @model.is_a?(Byt5Onnx)
|
|
15
|
+
|
|
23
16
|
input = ['<sos>'] + phrase.chars + ['<eos>']
|
|
24
|
-
input =
|
|
25
|
-
output =
|
|
26
|
-
src_key_padding_mask = input.
|
|
17
|
+
input = Numo::NArray[input.map { |i| @model.input_vocab.stoi[i] }].transpose
|
|
18
|
+
output = Numo::NArray[[@model.target_vocab.stoi['<sos>']]]
|
|
19
|
+
src_key_padding_mask = input.transpose.eq(1)
|
|
27
20
|
|
|
28
21
|
max_seq_length.times do |i|
|
|
29
|
-
tgt_key_padding_mask = output.
|
|
30
|
-
tgt_mask =
|
|
31
|
-
|
|
22
|
+
tgt_key_padding_mask = output.transpose.eq(1)
|
|
23
|
+
tgt_mask = Numo::DFloat.ones(i + 1, i + 1).triu.transpose.eq(0)
|
|
24
|
+
prediction = @model.argmax(input, output.dup,
|
|
32
25
|
tgt_mask: tgt_mask,
|
|
33
26
|
src_key_padding_mask: src_key_padding_mask,
|
|
34
27
|
tgt_key_padding_mask: tgt_key_padding_mask,
|
|
35
|
-
memory_key_padding_mask: src_key_padding_mask
|
|
36
|
-
|
|
37
|
-
prediction = @model.call(input, output, opts).map {|i| i.argmax.item }
|
|
38
|
-
break if @target_vocab.itos[prediction[i]] == '<eos>'
|
|
39
|
-
output = Torch.cat([output, Torch.tensor([[prediction[i]]])])
|
|
40
|
-
end
|
|
28
|
+
memory_key_padding_mask: src_key_padding_mask)
|
|
29
|
+
break if @model.target_vocab.itos[prediction[i]] == '<eos>'
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
|
|
31
|
+
output = Numo::NArray.concatenate([output, Numo::NArray[[prediction[i]]]])
|
|
32
|
+
end
|
|
44
33
|
|
|
45
|
-
|
|
46
|
-
def load_vocabs
|
|
47
|
-
@input_vocab = Vocab.new(JSON.parse(File.read("#{@vocabs_dir}/input_vocab.json")))
|
|
48
|
-
@target_vocab = Vocab.new(JSON.parse(File.read("#{@vocabs_dir}/target_vocab.json")))
|
|
34
|
+
output[1..-1].to_a.flatten.map { |i| @model.target_vocab.itos[i] }.join('')
|
|
49
35
|
end
|
|
50
36
|
end
|
|
51
|
-
end
|
|
37
|
+
end
|
data/lib/secryst/version.rb
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
module Secryst
|
|
2
|
-
VERSION = "
|
|
3
|
-
end
|
|
2
|
+
VERSION = "1.0.0"
|
|
3
|
+
end
|
data/lib/secryst/vocab.rb
CHANGED
|
@@ -1,66 +1,39 @@
|
|
|
1
1
|
module Secryst
|
|
2
2
|
class Vocab
|
|
3
3
|
UNK = "<unk>"
|
|
4
|
-
attr_reader :stoi, :itos
|
|
4
|
+
attr_reader :stoi, :itos
|
|
5
5
|
|
|
6
6
|
def initialize(
|
|
7
|
-
|
|
8
|
-
vectors: nil, unk_init: nil, vectors_cache: nil, specials_first: true
|
|
7
|
+
list, specials: ["<unk>", "<pad>", "<sos>", "<eos>"], specials_first: true
|
|
9
8
|
)
|
|
10
|
-
|
|
11
|
-
@freqs = counter
|
|
12
|
-
counter = counter.dup
|
|
13
|
-
min_freq = [min_freq, 1].max
|
|
14
|
-
|
|
15
|
-
@itos = []
|
|
16
9
|
@unk_index = nil
|
|
17
|
-
|
|
18
|
-
if specials_first
|
|
10
|
+
@itos = []
|
|
11
|
+
if specials_first && (list & specials).length == 0
|
|
19
12
|
@itos = specials
|
|
20
|
-
# only extend max size if specials are prepended
|
|
21
|
-
max_size += specials.size if max_size
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# frequencies of special tokens are not counted when building vocabulary
|
|
25
|
-
# in frequency order
|
|
26
|
-
specials.each do |tok|
|
|
27
|
-
counter.delete(tok)
|
|
28
13
|
end
|
|
29
14
|
|
|
30
|
-
|
|
31
|
-
words_and_frequencies = counter.sort_by { |k, v| [-v, k] }
|
|
15
|
+
@itos += list
|
|
32
16
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@itos << word
|
|
17
|
+
if !specials_first && (list & specials).length == 0
|
|
18
|
+
@itos.concat(specials)
|
|
36
19
|
end
|
|
37
20
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@
|
|
42
|
-
|
|
21
|
+
# Automatic substitution of unknown symbols
|
|
22
|
+
if @itos.include?("<unk>")
|
|
23
|
+
unk_index = @itos.index("<unk>")
|
|
24
|
+
@stoi = Hash.new(unk_index)
|
|
25
|
+
elsif @itos.include?("[UNK]")
|
|
26
|
+
unk_index = @itos.index("[UNK]")
|
|
27
|
+
@stoi = Hash.new(unk_index)
|
|
43
28
|
else
|
|
44
29
|
@stoi = {}
|
|
45
30
|
end
|
|
46
31
|
|
|
47
|
-
if !specials_first
|
|
48
|
-
@itos.concat(specials)
|
|
49
|
-
end
|
|
50
32
|
|
|
51
33
|
# stoi is simply a reverse dict for itos
|
|
52
34
|
@itos.each_with_index do |tok, i|
|
|
53
35
|
@stoi[tok] = i
|
|
54
36
|
end
|
|
55
|
-
|
|
56
|
-
@vectors = nil
|
|
57
|
-
if !vectors.nil?
|
|
58
|
-
# self.load_vectors(vectors, unk_init=unk_init, cache=vectors_cache)
|
|
59
|
-
raise "Not implemented yet"
|
|
60
|
-
else
|
|
61
|
-
raise "Failed assertion" unless unk_init.nil?
|
|
62
|
-
raise "Failed assertion" unless vectors_cache.nil?
|
|
63
|
-
end
|
|
64
37
|
end
|
|
65
38
|
|
|
66
39
|
def [](token)
|
|
@@ -71,18 +44,5 @@ module Secryst
|
|
|
71
44
|
@itos.length
|
|
72
45
|
end
|
|
73
46
|
alias_method :size, :length
|
|
74
|
-
|
|
75
|
-
def self.build_vocab_from_iterator(iterator)
|
|
76
|
-
counter = Hash.new(0)
|
|
77
|
-
i = 0
|
|
78
|
-
iterator.each do |tokens|
|
|
79
|
-
tokens.each do |token|
|
|
80
|
-
counter[token] += 1
|
|
81
|
-
end
|
|
82
|
-
i += 1
|
|
83
|
-
puts "Processed #{i}" if i % 10000 == 0
|
|
84
|
-
end
|
|
85
|
-
Vocab.new(counter)
|
|
86
|
-
end
|
|
87
47
|
end
|
|
88
48
|
end
|
data/lib/secryst.rb
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
require '
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'zip'
|
|
3
|
+
require 'numo/narray'
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
require
|
|
5
|
+
require 'secryst/vocab'
|
|
6
|
+
require 'secryst/imf'
|
|
7
|
+
require 'secryst/byt5_onnx'
|
|
8
|
+
require 'secryst/translator'
|
|
9
|
+
require 'secryst/model'
|
|
10
|
+
require 'secryst/provisioning'
|
|
5
11
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
module Secryst
|
|
13
|
+
DEFAULT_HYPERPARAMETERS = {
|
|
14
|
+
d_model: 64,
|
|
15
|
+
nhead: 8,
|
|
16
|
+
num_encoder_layers: 4,
|
|
17
|
+
num_decoder_layers: 4,
|
|
18
|
+
dim_feedforward: 256,
|
|
19
|
+
dropout: 0.05,
|
|
20
|
+
activation: 'relu',
|
|
21
|
+
}.freeze
|
|
22
|
+
end
|
|
Binary file
|
data/spec/parity_spec.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "json"
|
|
3
|
+
require "secryst/byt5_onnx"
|
|
4
|
+
|
|
5
|
+
# Cross-crystal parity (interscript-ml v1, requirement C5): the Ruby
|
|
6
|
+
# crystal must reproduce the Python reference goldens byte-for-byte on
|
|
7
|
+
# the deterministic decode-loop fixture. CI checks out secryst/secryst-py
|
|
8
|
+
# (parity/ = zip + golden.jsonl); locally point SECRYST_PARITY_DIR at it.
|
|
9
|
+
parity_dir = ENV["SECRYST_PARITY_DIR"] || File.expand_path("../../secryst-py/parity", __dir__)
|
|
10
|
+
zip_path = File.join(parity_dir, "tiny-1.0.zip")
|
|
11
|
+
goldens_path = File.join(parity_dir, "golden.jsonl")
|
|
12
|
+
|
|
13
|
+
describe "cross-crystal parity" do
|
|
14
|
+
let(:model) { Secryst::Byt5Onnx.new(zip_path) }
|
|
15
|
+
|
|
16
|
+
it "reproduces the reference goldens exactly" do
|
|
17
|
+
skip "parity kit not found (#{parity_dir})" unless File.exist?(goldens_path)
|
|
18
|
+
|
|
19
|
+
goldens = File.readlines(goldens_path).map { |l| JSON.parse(l) }
|
|
20
|
+
expect(goldens).not_to be_empty
|
|
21
|
+
|
|
22
|
+
failures = goldens.reject do |row|
|
|
23
|
+
model.translate(row["input"], max_seq_length: 8) == row["output"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
aggregate_failures "golden diff" do
|
|
27
|
+
failures.each do |row|
|
|
28
|
+
expect(model.translate(row["input"], max_seq_length: 8))
|
|
29
|
+
.to eq(row["output"]), "input #{row['input'].inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'secryst/byt5_onnx'
|
|
3
|
+
|
|
4
|
+
RSpec.describe Secryst::Byt5Onnx do
|
|
5
|
+
it 'uses the ByT5 byte conventions' do
|
|
6
|
+
expect(Secryst::IMF::PAD_ID).to eq(0)
|
|
7
|
+
expect(Secryst::IMF::EOS_ID).to eq(1)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
describe 'byte round-trip (as used for generated output)' do
|
|
11
|
+
it 'packs generated bytes back into UTF-8 text' do
|
|
12
|
+
text = 'ភាសាខ្មែរ'
|
|
13
|
+
bytes = text.bytes
|
|
14
|
+
expect(bytes.pack('C*').force_encoding(Encoding::UTF_8)).to eq(text)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|