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.
@@ -0,0 +1,180 @@
1
+ require 'spec_helper'
2
+ require 'secryst'
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+ require 'json'
6
+
7
+ RSpec.describe Secryst::IMF do
8
+ it 'uses the canonical ByT5 table (byte + 3, trailing EOS)' do
9
+ expect(described_class.encode('rok')).to eq([117, 114, 110, 1])
10
+ expect(described_class.decode([117, 114, 110])).to eq('rok')
11
+ expect(described_class.decode([117, 1, 114])).to eq('r')
12
+ end
13
+
14
+ describe 'with the tiny IMF fixture zip' do
15
+ let(:zip_path) { File.expand_path('../fixtures/tiny-imf.zip', __dir__) }
16
+
17
+ it 'parses the manifest and verifies graph checksums' do
18
+ meta = described_class.manifest(zip_path)
19
+ expect(meta['format']).to eq('imf-v1')
20
+ expect(meta['tokenizer']).to eq('bytes')
21
+ graphs = described_class.verify_and_read(zip_path)
22
+ expect(graphs.keys.sort).to eq(['decoder.onnx', 'encoder.onnx'])
23
+ end
24
+
25
+ it 'loads sessions from verified bytes' do
26
+ model = Secryst::Byt5Onnx.new(zip_path)
27
+ expect(model.id).to eq('tiny-1.0')
28
+ end
29
+
30
+ it 'decodes a real zip against the shared golden set', :e2e do
31
+ zip = ENV['SECRYST_E2E_ZIP']
32
+ golden = ENV['SECRYST_GOLDEN']
33
+ skip 'set SECRYST_E2E_ZIP and SECRYST_GOLDEN for the end-to-end run' unless zip && golden
34
+ translator = Secryst::Translator.new(model_file: zip)
35
+ ok = 0
36
+ total = 0
37
+ File.readlines(golden).each do |line|
38
+ row = JSON.parse(line)
39
+ total += 1
40
+ ok += 1 if translator.translate(row['input'], max_seq_length: 128) == row['output']
41
+ end
42
+ expect(ok).to eq(total)
43
+ end
44
+ end
45
+
46
+ it 'rejects a tampered zip loudly' do
47
+ Dir.mktmpdir do |tmp|
48
+ source = File.expand_path('../fixtures/tiny-imf.zip', __dir__)
49
+ tampered = File.join(tmp, 'tampered.zip')
50
+ require 'zip'
51
+ Zip::File.open(source) do |src|
52
+ Zip::File.open(tampered, create: true) do |dst|
53
+ src.entries.each do |e|
54
+ if e.name == 'encoder.onnx'
55
+ dst.get_output_stream(e.name) { |io| io.write('corrupted-bytes') }
56
+ else
57
+ dst.get_output_stream(e.name) { |io| io.write(e.get_input_stream.read) }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ expect { described_class.verify_and_read(tampered) }
63
+ .to raise_error(Secryst::IMF::FormatError, /sha256 mismatch/)
64
+ end
65
+ end
66
+
67
+ describe '.resolve' do
68
+ it 'installs a verified copy into the cache from a local index' do
69
+ Dir.mktmpdir do |tmp|
70
+ channel = File.join(tmp, 'channel')
71
+ FileUtils.mkdir_p(channel)
72
+ zip_path = File.expand_path('../fixtures/tiny-imf.zip', __dir__)
73
+ FileUtils.cp(zip_path, File.join(channel, 'tiny.zip'))
74
+ require 'digest'
75
+ index = File.join(tmp, 'models.yaml')
76
+ File.write(index, <<~YAML)
77
+ version: 1
78
+ models:
79
+ tiny-1.0:
80
+ filename: tiny.zip
81
+ url: file://#{channel}/tiny.zip
82
+ sha256: #{Digest::SHA256.file(zip_path).hexdigest}
83
+ YAML
84
+ cache = File.join(tmp, 'cache')
85
+ result = described_class.resolve('tiny-1.0', index_url: index) if false
86
+ # env-based cache (the public API reads ENV at call time)
87
+ ENV['SECRYST_CACHE'] = cache
88
+ begin
89
+ installed = described_class.resolve('tiny-1.0', index_url: index)
90
+ expect(installed).to eq(File.join(cache, 'models', 'tiny-1.0', 'tiny.zip'))
91
+ expect(File.file?(installed)).to be(true)
92
+ FileUtils.rm_f(File.join(channel, 'tiny.zip'))
93
+ expect(described_class.resolve('tiny-1.0', index_url: index)).to eq(installed)
94
+ ensure
95
+ ENV.delete('SECRYST_CACHE')
96
+ end
97
+ end
98
+ end
99
+
100
+ it 'raises for unknown ids' do
101
+ Dir.mktmpdir do |tmp|
102
+ index = File.join(tmp, 'models.yaml')
103
+ File.write(index, "version: 1\nmodels: {}\n")
104
+ expect { described_class.resolve('nope-1.0', index_url: index) }
105
+ .to raise_error(Secryst::IMF::RegistryError, /unknown model id/)
106
+ end
107
+ end
108
+
109
+ it 'assembles and verifies split parts (the >2GiB GitHub cap path)' do
110
+ Dir.mktmpdir do |tmp|
111
+ channel = File.join(tmp, 'channel')
112
+ FileUtils.mkdir_p(channel)
113
+ zip_path = File.expand_path('../fixtures/tiny-imf.zip', __dir__)
114
+ blob = File.binread(zip_path)
115
+ part_a, part_b = blob[0, (blob.bytesize / 2 + 3)], blob[(blob.bytesize / 2 + 3)..]
116
+ File.binwrite(File.join(channel, 'tiny.zip.part-00'), part_a)
117
+ File.binwrite(File.join(channel, 'tiny.zip.part-01'), part_b)
118
+ index = File.join(tmp, 'models.yaml')
119
+ File.write(index, <<~YAML)
120
+ version: 1
121
+ models:
122
+ tiny-1.0:
123
+ filename: tiny.zip
124
+ sha256: #{Digest::SHA256.hexdigest(blob)}
125
+ parts:
126
+ - url: file://#{channel}/tiny.zip.part-00
127
+ sha256: #{Digest::SHA256.hexdigest(part_a)}
128
+ size: #{part_a.bytesize}
129
+ - url: file://#{channel}/tiny.zip.part-01
130
+ sha256: #{Digest::SHA256.hexdigest(part_b)}
131
+ size: #{part_b.bytesize}
132
+ YAML
133
+ cache = File.join(tmp, 'cache')
134
+ ENV['SECRYST_CACHE'] = cache
135
+ begin
136
+ installed = described_class.resolve('tiny-1.0', index_url: index)
137
+ expect(File.binread(installed)).to eq(blob)
138
+ FileUtils.rm_f(File.join(channel, 'tiny.zip.part-00'))
139
+ expect(described_class.resolve('tiny-1.0', index_url: index)).to eq(installed)
140
+ ensure
141
+ ENV.delete('SECRYST_CACHE')
142
+ end
143
+ end
144
+ end
145
+
146
+ it 'rejects a corrupt part by index' do
147
+ Dir.mktmpdir do |tmp|
148
+ channel = File.join(tmp, 'channel')
149
+ FileUtils.mkdir_p(channel)
150
+ zip_path = File.expand_path('../fixtures/tiny-imf.zip', __dir__)
151
+ blob = File.binread(zip_path)
152
+ part_a, part_b = blob[0, 7], blob[7..]
153
+ File.binwrite(File.join(channel, 'tiny.zip.part-00'), part_a)
154
+ File.binwrite(File.join(channel, 'tiny.zip.part-01'), part_b)
155
+ index = File.join(tmp, 'models.yaml')
156
+ File.write(index, <<~YAML)
157
+ version: 1
158
+ models:
159
+ tiny-1.0:
160
+ filename: tiny.zip
161
+ sha256: #{Digest::SHA256.hexdigest(blob)}
162
+ parts:
163
+ - url: file://#{channel}/tiny.zip.part-00
164
+ sha256: #{"0" * 64}
165
+ size: #{part_a.bytesize}
166
+ - url: file://#{channel}/tiny.zip.part-01
167
+ sha256: #{Digest::SHA256.hexdigest(part_b)}
168
+ size: #{part_b.bytesize}
169
+ YAML
170
+ ENV['SECRYST_CACHE'] = File.join(tmp, 'cache')
171
+ begin
172
+ expect { described_class.resolve('tiny-1.0', index_url: index) }
173
+ .to raise_error(Secryst::IMF::RegistryError, /part 0 .* sha256 mismatch/)
174
+ ensure
175
+ ENV.delete('SECRYST_CACHE')
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
3
+ require "secryst"
metadata CHANGED
@@ -1,29 +1,71 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: secryst
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
- - project_contibutors
7
+ - Interscript / Secryst contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-10-05 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: torch-rb
14
+ name: thor
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.4'
19
+ version: '1.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.4'
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: numo-narray
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.9'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.9'
41
+ - !ruby/object:Gem::Dependency
42
+ name: onnxruntime
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.6'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.6'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubyzip
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.3'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.3'
27
69
  - !ruby/object:Gem::Dependency
28
70
  name: rake
29
71
  requirement: !ruby/object:Gem::Requirement
@@ -52,27 +94,39 @@ dependencies:
52
94
  - - ">="
53
95
  - !ruby/object:Gem::Version
54
96
  version: '0'
55
- description: Seq2seq transformer for transliteration in Ruby.
97
+ description: 'Secryst (scrying + crystal) reveals the hidden reading of a script —
98
+ diacritization, vocalization, grapheme-to-phoneme — via local, sha256-verified IMF
99
+ v1 ONNX models. Implements the interscript-ml contract (models.yaml index, byte
100
+ tokenizer, golden parity). Sibling crystals: pip install secryst, npm i secryst.'
56
101
  email:
57
- executables: []
102
+ executables:
103
+ - secryst
58
104
  extensions: []
59
105
  extra_rdoc_files: []
60
106
  files:
61
107
  - README.adoc
62
- - lib/secryst-trainer.rb
108
+ - bin/secryst
63
109
  - lib/secryst.rb
64
- - lib/secryst/clip_grad_norm.rb
110
+ - lib/secryst/byt5_onnx.rb
111
+ - lib/secryst/imf.rb
112
+ - lib/secryst/model.rb
65
113
  - lib/secryst/multi_head_attention_forward.rb
66
- - lib/secryst/multihead_attention.rb
67
- - lib/secryst/trainer.rb
68
- - lib/secryst/transformer.rb
114
+ - lib/secryst/provisioning.rb
69
115
  - lib/secryst/translator.rb
70
116
  - lib/secryst/version.rb
71
117
  - lib/secryst/vocab.rb
72
- homepage: https://github.com/secryst/secryst
118
+ - spec/fixtures/tiny-imf.zip
119
+ - spec/parity_spec.rb
120
+ - spec/secryst/byt5_onnx_spec.rb
121
+ - spec/secryst/imf_spec.rb
122
+ - spec/spec_helper.rb
123
+ homepage: https://www.secryst.org
73
124
  licenses:
74
125
  - BSD-2-Clause
75
- metadata: {}
126
+ metadata:
127
+ homepage_uri: https://www.secryst.org
128
+ source_code_uri: https://github.com/secryst/secryst
129
+ changelog_uri: https://github.com/secryst/secryst/blob/master/README.adoc
76
130
  post_install_message:
77
131
  rdoc_options: []
78
132
  require_paths:
@@ -88,8 +142,9 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
142
  - !ruby/object:Gem::Version
89
143
  version: '0'
90
144
  requirements: []
91
- rubygems_version: 3.1.2
145
+ rubygems_version: 3.5.22
92
146
  signing_key:
93
147
  specification_version: 4
94
- summary: Seq2seq transformer for transliteration in Ruby.
148
+ summary: 'Ruby crystal: local ONNX vocalization/G2P implementing the interscript-ml
149
+ contract.'
95
150
  test_files: []
@@ -1,25 +0,0 @@
1
- # ported from https://pytorch.org/docs/master/_modules/torch/nn/utils/clip_grad.html#clip_grad_norm_
2
-
3
- module Secryst
4
- class ClipGradNorm < Torch::NN::F
5
- def self.clip_grad_norm(parameters, max_norm:, norm_type:2)
6
- parameters = parameters.select {|p| p.grad }
7
- max_norm = max_norm.to_f
8
- if parameters.length == 0
9
- return Torch.tensor(0.0)
10
- end
11
- device = parameters[0].grad.device
12
- if norm_type == Float::INFINITY
13
- # ... TODO
14
- else
15
- total_norm = Numo::Linalg.norm(Numo::NArray.concatenate(parameters.map {|p| Numo::Linalg.norm(p.grad.detach.numo, norm_type)}), norm_type)
16
- end
17
- clip_coef = max_norm / (total_norm + 1e-6)
18
- if clip_coef < 1
19
- parameters.each {|p| p.grad = p.grad.detach * clip_coef}
20
- end
21
-
22
- return total_norm
23
- end
24
- end
25
- end
@@ -1,156 +0,0 @@
1
- # ported from https://github.com/pytorch/pytorch/blob/4ae832e1060c72cb89de1d9693629783dbe0c9a6/torch/csrc/api/include/torch/nn/functional/activation.h
2
-
3
- require_relative 'multi_head_attention_forward'
4
- module Secryst
5
- class MultiheadAttention < Torch::NN::Module
6
- # Allows the model to jointly attend to information
7
- # from different representation subspaces.
8
- # See reference: Attention Is All You Need
9
- # .. math::
10
- # \text{MultiHead}(Q, K, V) = \text{Concat}(head_1,\dots,head_h)W^O
11
- # \text{where} head_i = \text{Attention}(QW_i^Q, KW_i^K, VW_i^V)
12
- # Args:
13
- # embed_dim: total dimension of the model.
14
- # num_heads: parallel attention heads.
15
- # dropout: a Dropout layer on attn_output_weights. Default: 0.0.
16
- # bias: add bias as module parameter. Default: true.
17
- # add_bias_kv: add bias to the key and value sequences at dim=0.
18
- # add_zero_attn: add a new batch of zeros to the key and
19
- # value sequences at dim=1.
20
- # kdim: total number of features in key. Default: nil.
21
- # vdim: total number of features in value. Default: nil.
22
- # Note: if kdim and vdim are nil, they will be set to embed_dim such that
23
- # query, key, and value have the same number of features.
24
- # Examples::
25
- # >>> multihead_attn = MultiheadAttention.new(embed_dim: embed_dim, num_heads: num_heads)
26
- # >>> attn_output, attn_output_weights = multihead_attn(query, key, value)
27
- # bias_k: Optional[Torch::Tensor]
28
- # bias_v: Optional[Torch::Tensor]
29
-
30
- def initialize(embed_dim, num_heads, dropout:0.0, bias: true, add_bias_kv: false, add_zero_attn: false, kdim: nil, vdim: nil)
31
- super()
32
- @embed_dim = embed_dim
33
- @kdim = kdim || embed_dim
34
- @vdim = vdim || embed_dim
35
- @_qkv_same_embed_dim = @kdim == @embed_dim && @vdim == @embed_dim
36
-
37
- @num_heads = num_heads
38
- @dropout = dropout
39
- @head_dim = embed_dim / num_heads
40
- raise ArgumentError, "embed_dim must be divisible by num_heads" if @head_dim * num_heads != @embed_dim
41
-
42
- if !@_qkv_same_embed_dim
43
- @q_proj_weight = Torch::NN::Parameter.new(Torch::Tensor.new(embed_dim, embed_dim))
44
- @k_proj_weight = Torch::NN::Parameter.new(Torch::Tensor.new(embed_dim, @kdim))
45
- @v_proj_weight = Torch::NN::Parameter.new(Torch::Tensor.new(embed_dim, @vdim))
46
- register_parameter('in_proj_weight', nil)
47
- else
48
- @in_proj_weight = Torch::NN::Parameter.new(Torch.empty(3 * embed_dim, embed_dim))
49
- register_parameter('q_proj_weight', nil)
50
- register_parameter('k_proj_weight', nil)
51
- register_parameter('v_proj_weight', nil)
52
- end
53
-
54
- if bias
55
- @in_proj_bias = Torch::NN::Parameter.new(Torch.empty(3 * embed_dim))
56
- else
57
- register_parameter('in_proj_bias', nil)
58
- end
59
- @out_proj = Torch::NN::Linear.new(embed_dim, embed_dim)
60
-
61
- if add_bias_kv
62
- @bias_k = Torch::NN::Parameter.new(Torch.empty(1, 1, embed_dim))
63
- @bias_v = Torch::NN::Parameter.new(Torch.empty(1, 1, embed_dim))
64
- else
65
- @bias_k = @bias_v = nil
66
- end
67
-
68
- @add_zero_attn = add_zero_attn
69
-
70
- _reset_parameters
71
- end
72
-
73
- def _reset_parameters
74
- if @_qkv_same_embed_dim
75
- Torch::NN::Init.xavier_uniform!(@in_proj_weight)
76
- else
77
- Torch::NN::Init.xavier_uniform!(@q_proj_weight)
78
- Torch::NN::Init.xavier_uniform!(@k_proj_weight)
79
- Torch::NN::Init.xavier_uniform!(@v_proj_weight)
80
- end
81
-
82
- if @in_proj_bias
83
- Torch::NN::Init.constant!(@in_proj_bias, 0.0)
84
- Torch::NN::Init.constant!(@out_proj.bias, 0.0)
85
- end
86
-
87
- if @bias_k
88
- Torch::NN::Init.xavier_normal!(@bias_k)
89
- end
90
-
91
- if @bias_v
92
- Torch::NN::Init.xavier_normal!(@bias_v)
93
- end
94
- end
95
-
96
- # Args:
97
- # query, key, value: map a query and a set of key-value pairs to an output.
98
- # See "Attention Is All You Need" for more details.
99
- # key_padding_mask: if provided, specified padding elements in the key will
100
- # be ignored by the attention. When given a binary mask and a value is true,
101
- # the corresponding value on the attention layer will be ignored. When given
102
- # a byte mask and a value is non-zero, the corresponding value on the attention
103
- # layer will be ignored
104
- # need_weights: output attn_output_weights.
105
- # attn_mask: 2D or 3D mask that prevents attention to certain positions. A 2D mask will be broadcasted for all
106
- # the batches while a 3D mask allows to specify a different mask for the entries of each batch.
107
- # Shape:
108
- # - Inputs:
109
- # - query: :math:`(L, N, E)` where L is the target sequence length, N is the batch size, E is
110
- # the embedding dimension.
111
- # - key: :math:`(S, N, E)`, where S is the source sequence length, N is the batch size, E is
112
- # the embedding dimension.
113
- # - value: :math:`(S, N, E)` where S is the source sequence length, N is the batch size, E is
114
- # the embedding dimension.
115
- # - key_padding_mask: :math:`(N, S)` where N is the batch size, S is the source sequence length.
116
- # If a ByteTensor is provided, the non-zero positions will be ignored while the position
117
- # with the zero positions will be unchanged. If a BoolTensor is provided, the positions with the
118
- # value of ``true`` will be ignored while the position with the value of ``false`` will be unchanged.
119
- # - attn_mask: 2D mask :math:`(L, S)` where L is the target sequence length, S is the source sequence length.
120
- # 3D mask :math:`(N*num_heads, L, S)` where N is the batch size, L is the target sequence length,
121
- # S is the source sequence length. attn_mask ensure that position i is allowed to attend the unmasked
122
- # positions. If a ByteTensor is provided, the non-zero positions are not allowed to attend
123
- # while the zero positions will be unchanged. If a BoolTensor is provided, positions with ``true``
124
- # is not allowed to attend while ``false`` values will be unchanged. If a FloatTensor
125
- # is provided, it will be added to the attention weight.
126
- # - Outputs:
127
- # - attn_output: :math:`(L, N, E)` where L is the target sequence length, N is the batch size,
128
- # E is the embedding dimension.
129
- # - attn_output_weights: :math:`(N, L, S)` where N is the batch size,
130
- # L is the target sequence length, S is the source sequence length.
131
- def forward(query, key, value, key_padding_mask:nil,
132
- need_weights:true, attn_mask:nil)
133
- if !@_qkv_same_embed_dim
134
- return Secryst::MultiHeadAttentionForward.multi_head_attention_forward(
135
- query, key, value, @embed_dim, @num_heads,
136
- @in_proj_weight, @in_proj_bias,
137
- @bias_k, @bias_v, @add_zero_attn,
138
- @dropout, @out_proj.weight, @out_proj.bias,
139
- training: @training,
140
- key_padding_mask: key_padding_mask, need_weights: need_weights,
141
- attn_mask: attn_mask, use_separate_proj_weight: true,
142
- q_proj_weight: @q_proj_weight, k_proj_weight: @k_proj_weight,
143
- v_proj_weight: @v_proj_weight)
144
- else
145
- return Secryst::MultiHeadAttentionForward.multi_head_attention_forward(
146
- query, key, value, @embed_dim, @num_heads,
147
- @in_proj_weight, @in_proj_bias,
148
- @bias_k, @bias_v, @add_zero_attn,
149
- @dropout, @out_proj.weight, @out_proj.bias,
150
- training: @training,
151
- key_padding_mask: key_padding_mask, need_weights: need_weights,
152
- attn_mask: attn_mask)
153
- end
154
- end
155
- end
156
- end