laya 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 69d7ddd3d48590f822730d724339adf76897e8eefe51e3fb1bb6aff35fa9d5de
4
+ data.tar.gz: 62fa0d3a2491c0e65d13da93712498e96249561e01859488eb8a50f5e5721fe7
5
+ SHA512:
6
+ metadata.gz: a83ae9a6a5f0040399b4e5ec638cc3c87e4101cf3b058ed6fb00d064cd0fc68d8f6262c58ad85f36420b1879f2be214f66205e59560246ca97f9c6759b968726
7
+ data.tar.gz: a03f5b5c4563c2be24f13907acfdfdc131d2d74b7bee886b7919475593f20c6e32e7b76642fa0eab3141c20aada95a3de1b0fea57f14d6edaf3fec446fa17ea6
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - `Laya.configure`, `Laya.new` and `Laya::Client#system_one` for `choice`, `score` and `noul` questions
6
+ - Lazy, thread-safe model loading, with `load!` for preloading before fork
7
+ - Hugging Face download into a cache: `Laya.download`, the `laya download|path|help` command, `laya:download` / `laya:path` Rake tasks and a Railtie
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eduardo Hernandez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # laya
2
+
3
+ Run [Laya](https://laya.convaiinnovations.com/), Convai Innovations' open-source "System-1" decision model, from Ruby. It runs locally on the CPU through ONNX Runtime.
4
+
5
+ You give Laya a **state** (a ticket, an email, any JSON) and some **typed questions**. It returns every answer with calibrated probabilities in a single forward pass, in a few hundred milliseconds on a laptop CPU.
6
+
7
+ ```ruby
8
+ laya = Laya.new
9
+
10
+ result = laya.system_one(
11
+ {
12
+ subject: "Refund not received",
13
+ body: "I cancelled my subscription two weeks ago and I still have not received my refund. " \
14
+ "This is the third time I am writing. If this is not resolved I will dispute the charge with my bank."
15
+ },
16
+ {
17
+ department: {type: :choice, instructions: "Which team should handle this ticket?",
18
+ criteria: {billing: "payments, refunds, invoices", support: "product help and bugs", sales: "new purchases"}},
19
+ churn_risk: {type: :noul, instructions: "Is the customer likely to cancel or dispute?"}
20
+ }
21
+ )
22
+
23
+ result[:department].choice # => "billing"
24
+ result[:churn_risk].noul # => 0.0988
25
+ ```
26
+
27
+ ## Installation
28
+
29
+ ```ruby
30
+ # Gemfile
31
+ gem "laya"
32
+ ```
33
+
34
+ This requires Ruby 3.3+. The runtime dependencies are [`onnxruntime`](https://github.com/ankane/onnxruntime-ruby) and [`tokenizers`](https://github.com/ankane/tokenizers-ruby), which ship prebuilt binaries.
35
+
36
+ The model itself is about 1.7 GB, published at [receptron/laya-onnx](https://huggingface.co/receptron/laya-onnx). It downloads on first use into `~/.cache/receptron-laya`, so a machine running both downloads it only once. You can also fetch it ahead of time; see [Downloading the model](#downloading-the-model). Budget about 2 GB of RAM once it's loaded.
37
+
38
+ ## Asking questions
39
+
40
+ `system_one(state, questions)` answers every question about one state in a single forward pass.
41
+
42
+ - **`state`** is a String, or anything JSON-serializable (Hash, Array, numbers). Long states are truncated to the model's 512-token window.
43
+ - **`questions`** is a Hash of `name => question`. Names and `type` can be Symbols or Strings, and the answers come back under the same keys.
44
+
45
+ ### The three question types
46
+
47
+ The `choice` and `score` examples below use the ticket state from the top of this README.
48
+
49
+ | Type | Use it for | `criteria` | Answer |
50
+ | --- | --- | --- | --- |
51
+ | `choice` | Picking one option | `{label => description}`, or `[label, ...]` | `choice`, `probabilities`, `confidence` |
52
+ | `score` | A level on an ordered scale | `[lowest, ..., highest]` | `score`, `probabilities`, `legend`, `confidence` |
53
+ | `noul` | A yes/no probability | optional `{true => "...", false => "..."}` | `noul` |
54
+
55
+ **choice** picks the most likely label and gives the probability of each one:
56
+
57
+ ```ruby
58
+ department: {
59
+ type: :choice,
60
+ instructions: "Which team should handle this ticket?",
61
+ criteria: {billing: "payments, refunds, invoices", support: "product help and bugs", sales: "new purchases"}
62
+ }
63
+ # result[:department].choice # => "billing"
64
+ # result[:department].probabilities # => {"billing" => 0.9415, "support" => 0.031, "sales" => 0.0275}
65
+ # result[:department].confidence # => 0.7603 (1 = certain, 0 = evenly spread)
66
+ ```
67
+
68
+ **score** returns the expected level, from 0 up to the number of levels minus 1, plus the distribution:
69
+
70
+ ```ruby
71
+ urgency: {
72
+ type: :score,
73
+ instructions: "How urgent is this ticket?",
74
+ criteria: ["not urgent", "somewhat urgent", "urgent", "critical"]
75
+ }
76
+ # result[:urgency].score # => 1.3886 (between "somewhat urgent" and "urgent")
77
+ # result[:urgency].probabilities # => {"0" => 0.1752, "1" => 0.2947, "2" => 0.4962, "3" => 0.0338}
78
+ # result[:urgency].legend # => {"0" => "not urgent", "1" => "somewhat urgent", ...}
79
+ ```
80
+
81
+ **noul** returns P(true). The criteria are optional and let you say what true and false mean:
82
+
83
+ ```ruby
84
+ laya.system_one(
85
+ "Hi, my order #4521 arrived damaged, can I get a replacement?",
86
+ {
87
+ spam: {
88
+ type: :noul,
89
+ instructions: "Is this message spam?",
90
+ criteria: {true => "unsolicited advertising", false => "a genuine request"}
91
+ }
92
+ }
93
+ )[:spam].noul # => 0.1134
94
+ ```
95
+
96
+ Every answer also has `type` and `act_probability`. `result.usage.input_tokens` reports how many tokens were read, and `result.to_h` returns a plain Hash.
97
+
98
+ ### Tips from Laya's docs
99
+
100
+ - Keep `choice` under about 20 options. With many labels, each one gets only a few tokens and accuracy drops.
101
+ - `score` is the weakest of the three types, so it works best with coarse scales (3 to 5 levels).
102
+ - The probabilities are calibrated, so thresholds mean something. A common pattern is to act automatically above 0.85 confidence and escalate to a human below it.
103
+
104
+ See: https://huggingface.co/convaiinnovations/laya#where-jev-leads
105
+
106
+ ## Configuration
107
+
108
+ ```ruby
109
+ # config/initializers/laya.rb
110
+ Laya.configure do |config|
111
+ config.cache_dir = "/var/cache/laya"
112
+ config.session_options = {intra_op_num_threads: 4}
113
+ end
114
+ ```
115
+
116
+ | Setting | Default | |
117
+ | --- | --- | --- |
118
+ | `cache_dir` | `$LAYA_CACHE`, `$XDG_CACHE_HOME/receptron-laya` or `~/.cache/receptron-laya` | Where downloads go |
119
+ | `model_dir` | `nil` | Use local model files; nothing is downloaded |
120
+ | `token` | `$HF_TOKEN` | For private or gated Hugging Face repos |
121
+ | `repo` | `"receptron/laya-onnx"` | Hugging Face repo |
122
+ | `revision` | `"main"` | Branch, tag or commit to download |
123
+ | `subfolder` | `nil` | Subfolder inside the repo |
124
+ | `providers` | `["CPUExecutionProvider"]` | ONNX Runtime execution providers |
125
+ | `session_options` | `{}` | Passed to `OnnxRuntime::InferenceSession` |
126
+ | `on_progress` | `nil` | `->(file:, received:, total:) { ... }`, called during downloads |
127
+ | `logger` | `nil` | Logs each downloaded file |
128
+
129
+ `Laya.new(**overrides)` builds a client from the global configuration plus any overrides, for example `Laya.new(model_dir: "./onnx")`.
130
+
131
+ ### Loading and threads
132
+
133
+ `Laya.new` is cheap: the model loads on the first `system_one` call. To load it up front, call `load!`:
134
+
135
+ ```ruby
136
+ LAYA = Laya.new.load!
137
+ ```
138
+
139
+ With Puma or Unicorn and `preload_app!`, load before forking so all workers share the model's memory. Clients are thread-safe, and one client per process is enough. `laya.loaded?` tells you whether the model is in memory, and `laya.close` releases it.
140
+
141
+ ## Downloading the model
142
+
143
+ To avoid downloading 1.7 GB on the first request, fetch the model at build time.
144
+
145
+ **Rails:** the tasks are registered automatically and run after your initializers:
146
+
147
+ ```bash
148
+ bin/rails laya:download # fetch the model into the cache
149
+ bin/rails laya:path # print the model directory; fails if files are missing
150
+ ```
151
+
152
+ **Other Rake projects:** add `require "laya/tasks"` to your `Rakefile`, then run `rake laya:download`.
153
+
154
+ **Command line:**
155
+
156
+ ```console
157
+ $ bundle exec laya download
158
+ Downloading receptron/laya-onnx@main into /Users/you/.cache/receptron-laya/receptron--laya-onnx/main
159
+ laya.onnx.data 42% 708.1 MB / 1607.2 MB
160
+ ```
161
+
162
+ Run `bundle exec laya help` to see every command and environment variable, with its current value. The command prints the model directory on stdout, so `MODEL_DIR=$(laya path)` works in scripts.
163
+
164
+ **From Ruby:** `Laya.download` returns the directory.
165
+
166
+ **Docker:**
167
+
168
+ ```dockerfile
169
+ ENV LAYA_CACHE=/models
170
+ RUN SECRET_KEY_BASE_DUMMY=1 bin/rails laya:download
171
+ ```
172
+
173
+ The model then gets its own image layer, which stays cached until you change the revision.
174
+
175
+ These environment variables override the configuration in all of the above: `LAYA_CACHE`, `HF_TOKEN`, `LAYA_REPO`, `LAYA_REVISION`, `LAYA_SUBFOLDER`.
176
+
177
+ A download only fetches files that are missing or have changed size. Each file is written to a temporary file first, so an interrupted download never leaves a broken model behind. If the network is unavailable, a complete cache is used as-is.
178
+
179
+ ## Errors
180
+
181
+ Every error inherits from `Laya::Error`:
182
+
183
+ | Error | When |
184
+ | --- | --- |
185
+ | `Laya::InvalidQuestionError` | A question is malformed; the message names the question |
186
+ | `Laya::InputTooLongError` | A question's options don't fit in the model's window |
187
+ | `Laya::DownloadError` | Network or Hugging Face errors (401/403 hint at `HF_TOKEN`) |
188
+ | `Laya::ModelError` | The model files are corrupt or incompatible |
189
+ | `Laya::ConfigurationError` | An invalid setting, or `model_dir` is missing files |
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ bundle install
195
+ bundle exec rake # specs + StandardRB; no model needed
196
+ ```
197
+
198
+ The specs that need the real model are tagged `:model` and only run when `LAYA_MODEL_DIR` is set:
199
+
200
+ ```bash
201
+ bundle exec exe/laya download
202
+ LAYA_MODEL_DIR=$(bundle exec exe/laya path) bundle exec rspec
203
+ ```
204
+
205
+ ## License
206
+
207
+ The gem is MIT-licensed. The model weights are Apache 2.0, by [Convai Innovations](https://huggingface.co/convaiinnovations/laya). The ONNX export is by [receptron/laya](https://github.com/receptron/laya) (MIT).
data/exe/laya ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require "laya"
3
+
4
+ exit Laya::CLI.new.run(ARGV)
data/lib/laya/cli.rb ADDED
@@ -0,0 +1,113 @@
1
+ module Laya
2
+ # `laya download` / `laya path`, shared by exe/laya and the Rake tasks.
3
+ class CLI
4
+ EX_USAGE = 64
5
+ ENV_OVERRIDES = {
6
+ cache_dir: "LAYA_CACHE",
7
+ token: "HF_TOKEN",
8
+ repo: "LAYA_REPO",
9
+ revision: "LAYA_REVISION",
10
+ subfolder: "LAYA_SUBFOLDER"
11
+ }.freeze
12
+
13
+ def initialize(out: $stdout, err: $stderr, env: ENV)
14
+ @out = out
15
+ @err = err
16
+ @env = env
17
+ end
18
+
19
+ def run(argv)
20
+ case argv.first
21
+ when "download" then download
22
+ when "path" then path
23
+ when "help", "-h", "--help"
24
+ @out.puts help
25
+ 0
26
+ when nil
27
+ @err.puts help
28
+ EX_USAGE
29
+ else
30
+ @err.puts "laya: unknown command #{argv.first.inspect}", "", help
31
+ EX_USAGE
32
+ end
33
+ end
34
+
35
+ def help
36
+ settings = config
37
+ <<~HELP
38
+ laya #{VERSION}: manage the Laya model files
39
+
40
+ Usage:
41
+ laya download Download the model into the cache (~1.7 GB the first time)
42
+ laya path Print the model directory; exits 1 if files are missing
43
+ laya help Show this message
44
+
45
+ Environment (overrides Laya.configure):
46
+ LAYA_CACHE Cache directory #{settings.cache_dir}
47
+ HF_TOKEN Hugging Face token #{settings.token ? "set" : "not set"}
48
+ LAYA_REPO Hugging Face repo #{settings.repo}
49
+ LAYA_REVISION Branch, tag or commit #{settings.revision}
50
+ LAYA_SUBFOLDER Subfolder in the repo #{settings.subfolder || "none"}
51
+
52
+ Examples:
53
+ laya download
54
+ LAYA_CACHE=/models laya download
55
+ ls "$(laya path)"
56
+ HELP
57
+ end
58
+
59
+ private
60
+
61
+ def config
62
+ overrides = ENV_OVERRIDES.to_h { |setting, variable| [setting, @env[variable]] }.compact
63
+ Laya.config.merge(**overrides)
64
+ end
65
+
66
+ def download
67
+ settings = config
68
+ downloader = Downloader.new(settings.merge(on_progress: progress).validate!)
69
+ @err.puts "Downloading #{settings.repo}@#{settings.revision} into #{downloader.dir}" unless settings.model_dir
70
+ dir = downloader.call
71
+ @err.puts "Model ready in #{dir}"
72
+ @out.puts dir
73
+ 0
74
+ rescue Laya::Error => e
75
+ @err.puts "", "laya: #{e.message}"
76
+ 1
77
+ end
78
+
79
+ def path
80
+ downloader = Downloader.new(config)
81
+ missing_files = downloader.missing_files
82
+ if missing_files.empty?
83
+ @out.puts downloader.dir
84
+ 0
85
+ else
86
+ @err.puts "laya: #{downloader.dir} is missing:"
87
+ missing_files.each { |file| @err.puts " #{file}" }
88
+ @err.puts "Run `laya download` to fetch them."
89
+ 1
90
+ end
91
+ end
92
+
93
+ def progress
94
+ last_percent = {}
95
+ lambda do |file:, received:, total:|
96
+ percent = total&.positive? ? received * 100 / total : nil
97
+ next if percent && last_percent[file] == percent
98
+
99
+ last_percent[file] = percent
100
+ @err.print "\r #{file.ljust(32)} #{progress_detail(percent, received, total)}"
101
+ @err.puts if percent == 100
102
+ end
103
+ end
104
+
105
+ def progress_detail(percent, received, total)
106
+ return megabytes(received) unless percent
107
+
108
+ "#{percent.to_s.rjust(3)}% #{megabytes(received)} / #{megabytes(total)}"
109
+ end
110
+
111
+ def megabytes(bytes) = format("%.1f MB", bytes / 1_048_576.0)
112
+ end
113
+ end
@@ -0,0 +1,87 @@
1
+ module Laya
2
+ # Answers typed questions about a state. Loading is lazy (first call, or load!) and thread-safe.
3
+ class Client
4
+ attr_reader :config
5
+
6
+ def initialize(config, session: nil)
7
+ @config = config.validate!.freeze
8
+ @session = session
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def load!
13
+ session
14
+ self
15
+ end
16
+
17
+ def loaded? = !@session.nil?
18
+
19
+ def close
20
+ @mutex.synchronize { @session = nil }
21
+ nil
22
+ end
23
+
24
+ def system_one(state, questions)
25
+ normalized_questions = Questions.normalize_all(questions)
26
+ model = session
27
+ encoded = {}
28
+ encode = ->(text) { encoded[text] ||= model.encode(text) }
29
+
30
+ rows = normalized_questions.map do |key, question|
31
+ built = Sequence.build(encode, model.special_ids, state, question, max_len: model.max_len, head_max_len: model.head_max_len)
32
+ if built.markers.size != question.options.size
33
+ raise InputTooLongError, "question #{key.inspect}: options do not fit in head_max_len=#{model.head_max_len} tokens"
34
+ end
35
+
36
+ [built, question.type_id]
37
+ end
38
+
39
+ logits, act_probs = model.run(Sequence.collate(rows, pad: model.special_ids.pad))
40
+
41
+ answers = normalized_questions.each_with_index.to_h do |(key, question), row|
42
+ option_count = rows[row].first.markers.size
43
+ [key, decode(question, logits[row].first(option_count), act_probs[row][0], model.model_config)]
44
+ end
45
+
46
+ Result.new(
47
+ model: "laya",
48
+ answers: answers.freeze,
49
+ usage: Usage.new(input_tokens: rows.sum { |built, _| built.ids.size }, output_tokens: 0)
50
+ )
51
+ end
52
+
53
+ private
54
+
55
+ def session
56
+ @session || @mutex.synchronize do
57
+ @session ||= Session.new(Downloader.new(config).call, providers: config.providers, session_options: config.session_options)
58
+ end
59
+ end
60
+
61
+ def decode(question, logits, act_probability, model_config)
62
+ temperature = Sequence.temperature(model_config, question.type, logits.size)
63
+ probabilities = Sequence.softmax(logits.map { |logit| logit / temperature })
64
+
65
+ case question.type
66
+ when "choice"
67
+ labels = question.criteria.keys
68
+ ChoiceAnswer.new(
69
+ choice: labels[probabilities.index(probabilities.max)],
70
+ probabilities: labels.zip(probabilities.map { |probability| probability.round(4) }).to_h.freeze,
71
+ confidence: Sequence.confidence(probabilities).round(4),
72
+ act_probability: act_probability
73
+ )
74
+ when "score"
75
+ ScoreAnswer.new(
76
+ score: probabilities.each_with_index.sum { |probability, level| probability * level }.round(4),
77
+ probabilities: probabilities.each_with_index.to_h { |probability, level| [level.to_s, probability.round(4)] }.freeze,
78
+ legend: question.criteria.each_with_index.to_h { |description, level| [level.to_s, description] }.freeze,
79
+ confidence: Sequence.confidence(probabilities).round(4),
80
+ act_probability: act_probability
81
+ )
82
+ else
83
+ NoulAnswer.new(noul: probabilities[1].round(4), act_probability: act_probability)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,56 @@
1
+ module Laya
2
+ class Configuration
3
+ BUNDLE_FILES = %w[laya.onnx laya.onnx.data laya_config.json
4
+ tokenizer/tokenizer.json tokenizer/tokenizer_config.json].freeze
5
+ SETTINGS = %i[repo revision subfolder model_dir cache_dir token providers
6
+ session_options on_progress logger].freeze
7
+
8
+ attr_accessor(*SETTINGS)
9
+
10
+ def self.default_cache_dir(env)
11
+ env["LAYA_CACHE"] ||
12
+ File.join(env["XDG_CACHE_HOME"] ||
13
+ File.join(Dir.home, ".cache"), "receptron-laya")
14
+ end
15
+
16
+ def initialize(env: ENV)
17
+ @repo = "receptron/laya-onnx"
18
+ @revision = "main"
19
+ @subfolder = nil
20
+ @model_dir = nil
21
+ @cache_dir = self.class.default_cache_dir(env)
22
+ @token = env["HF_TOKEN"]
23
+ @providers = ["CPUExecutionProvider"]
24
+ @session_options = {}
25
+ @on_progress = nil
26
+ @logger = nil
27
+ end
28
+
29
+ def initialize_copy(source)
30
+ super
31
+ @providers = source.providers.dup
32
+ @session_options = source.session_options.dup
33
+ end
34
+
35
+ def merge(**overrides)
36
+ unknown = overrides.keys - SETTINGS
37
+ raise ConfigurationError, "unknown setting(s): #{unknown.join(", ")}" if unknown.any?
38
+
39
+ dup.tap { |copy| overrides.each { |name, value| copy.public_send(:"#{name}=", value) } }
40
+ end
41
+
42
+ def validate!
43
+ raise ConfigurationError, "repo must look like \"owner/name\" (got #{repo.inspect})" unless repo.to_s.match?(%r{\A[^/\s]+/[^/\s]+\z})
44
+ raise ConfigurationError, "revision can't be blank" if revision.to_s.empty?
45
+ raise ConfigurationError, "providers must be an Array" unless providers.is_a?(Array)
46
+ raise ConfigurationError, "session_options must be a Hash" unless session_options.is_a?(Hash)
47
+ raise ConfigurationError, "on_progress must respond to #call" if on_progress && !on_progress.respond_to?(:call)
48
+
49
+ if model_dir
50
+ missing = BUNDLE_FILES.reject { |file| File.file?(File.join(model_dir, file)) }
51
+ raise ConfigurationError, "model_dir #{model_dir} is missing: #{missing.join(", ")}" if missing.any?
52
+ end
53
+ self
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,130 @@
1
+ require "fileutils"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "uri"
5
+
6
+ module Laya
7
+ # Resolves the directory that holds the model files: model_dir as-is, or the Hugging Face cache.
8
+ # Files are fetched when missing or when their size differs from the remote one; each streams to a
9
+ # .part file that is renamed when complete, so an interrupted download never leaves a broken file.
10
+ class Downloader
11
+ ORIGIN = URI("https://huggingface.co")
12
+ MAX_REDIRECTS = 5
13
+ NETWORK_ERRORS = [SocketError, SystemCallError, IOError, Timeout::Error,
14
+ OpenSSL::SSL::SSLError, Net::HTTPBadResponse].freeze
15
+
16
+ def initialize(config)
17
+ @config = config
18
+ end
19
+
20
+ def call
21
+ return dir if @config.model_dir
22
+
23
+ Configuration::BUNDLE_FILES.each { |file| fetch(file) }
24
+ dir
25
+ end
26
+
27
+ def dir
28
+ return File.expand_path(@config.model_dir) if @config.model_dir
29
+
30
+ File.join(File.expand_path(@config.cache_dir), @config.repo.sub("/", "--"), @config.revision, *subfolder)
31
+ end
32
+
33
+ def missing_files
34
+ Configuration::BUNDLE_FILES.reject { |file| File.file?(File.join(dir, file)) }
35
+ end
36
+
37
+ private
38
+
39
+ def subfolder
40
+ name = @config.subfolder.to_s.gsub(%r{\A/+|/+\z}, "")
41
+ name.empty? ? [] : [name]
42
+ end
43
+
44
+ def url_for(file)
45
+ path = [@config.repo, "resolve", URI.encode_www_form_component(@config.revision), *subfolder, file].join("/")
46
+ "#{ORIGIN}/#{path}"
47
+ end
48
+
49
+ def fetch(file)
50
+ destination = File.join(dir, file)
51
+ url = url_for(file)
52
+ if File.file?(destination)
53
+ remote_size = remote_size(url)
54
+ return if remote_size.nil? || remote_size == File.size(destination)
55
+ end
56
+ download(url, destination, file)
57
+ end
58
+
59
+ # A failed HEAD (offline, DNS, 5xx) means "unknown", so a populated cache keeps working offline.
60
+ def remote_size(url)
61
+ request(Net::HTTP::Head, url) do |response, linked_size|
62
+ next Integer(linked_size, exception: false) if linked_size
63
+ next nil unless response.is_a?(Net::HTTPSuccess)
64
+
65
+ Integer(response["content-length"], exception: false)
66
+ end
67
+ rescue DownloadError, *NETWORK_ERRORS
68
+ nil
69
+ end
70
+
71
+ def download(url, destination, file)
72
+ FileUtils.mkdir_p(File.dirname(destination))
73
+ partial = "#{destination}.part-#{Process.pid}"
74
+ request(Net::HTTP::Get, url) do |response, _linked_size|
75
+ raise DownloadError, failure_message(url, response) unless response.is_a?(Net::HTTPSuccess)
76
+
77
+ write_body(response, partial, file, url)
78
+ end
79
+ File.rename(partial, destination)
80
+ @config.logger&.info("laya: downloaded #{file}")
81
+ rescue *NETWORK_ERRORS => e
82
+ raise DownloadError, "failed to download #{url}: #{e.message}"
83
+ ensure
84
+ FileUtils.rm_f(partial) if partial
85
+ end
86
+
87
+ def write_body(response, partial, file, url)
88
+ total = Integer(response["content-length"], exception: false)
89
+ received = 0
90
+ File.open(partial, "wb") do |output|
91
+ response.read_body do |chunk|
92
+ output.write(chunk)
93
+ received += chunk.bytesize
94
+ @config.on_progress&.call(file: file, received: received, total: total)
95
+ end
96
+ end
97
+ raise DownloadError, "incomplete download of #{url}: got #{received} of #{total} bytes" if total && received != total
98
+ end
99
+
100
+ def failure_message(url, response)
101
+ hint = [401, 403].include?(response.code.to_i) ? " (set HF_TOKEN or config.token for private or gated repos)" : ""
102
+ "failed to download #{url}: #{response.code} #{response.message}#{hint}"
103
+ end
104
+
105
+ # Follows redirects and yields the final response (with its connection still open, for streaming)
106
+ # plus the first x-linked-size seen, which Hugging Face only sends on the LFS redirect.
107
+ def request(verb, url, hops: MAX_REDIRECTS, linked_size: nil, &block)
108
+ uri = URI(url)
109
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 15, read_timeout: 60) do |http|
110
+ http.request(build_request(verb, uri)) do |response|
111
+ linked_size ||= response["x-linked-size"]
112
+ # A HEAD stops at the LFS redirect: x-linked-size already answers it, and CDNs often refuse HEAD
113
+ final = !response.is_a?(Net::HTTPRedirection) || (verb == Net::HTTP::Head && linked_size)
114
+ return yield(response, linked_size) if final
115
+ raise DownloadError, "too many redirects fetching #{url}" if hops.zero?
116
+
117
+ return request(verb, URI.join(uri, response["location"]).to_s, hops: hops - 1, linked_size: linked_size, &block)
118
+ end
119
+ end
120
+ end
121
+
122
+ def build_request(verb, uri)
123
+ verb.new(uri).tap do |request|
124
+ request["User-Agent"] = "laya-ruby/#{VERSION}"
125
+ request["Accept-Encoding"] = "identity" # sizes must match the bytes on disk
126
+ request["Authorization"] = "Bearer #{@config.token}" if @config.token && uri.host == ORIGIN.host
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,13 @@
1
+ module Laya
2
+ class Error < StandardError; end
3
+
4
+ class ConfigurationError < Error; end
5
+
6
+ class InvalidQuestionError < Error; end
7
+
8
+ class InputTooLongError < Error; end
9
+
10
+ class DownloadError < Error; end
11
+
12
+ class ModelError < Error; end
13
+ end
@@ -0,0 +1,99 @@
1
+ require "json"
2
+
3
+ module Laya
4
+ # Validates Jev-style question hashes and normalizes them (rl_agent_api.RLAgent._to_internal).
5
+ module Questions
6
+ TYPES = {"choice" => 0, "score" => 1, "noul" => 2}.freeze
7
+ NOUL_DEFAULTS = {
8
+ "false" => "no, the statement does not hold",
9
+ "true" => "yes, the statement holds"
10
+ }.freeze
11
+
12
+ Question = Data.define(:type, :instructions, :criteria) do
13
+ def type_id = TYPES.fetch(type)
14
+
15
+ def options
16
+ case type
17
+ when "choice"
18
+ criteria.map { |label, desc| desc.to_s.empty? ? label : "#{label}: #{desc}" }
19
+ when "score"
20
+ criteria.each_with_index.map { |level, i| "level #{i}: #{level}" }
21
+ else
22
+ %w[false true].map do |k|
23
+ "#{k}: #{criteria[k].to_s.empty? ? NOUL_DEFAULTS[k] : criteria[k]}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ module_function
30
+
31
+ def normalize_all(questions)
32
+ raise InvalidQuestionError, "questions must be a Hash of name => question" unless questions.is_a?(Hash)
33
+ raise InvalidQuestionError, "at least one question is required" if questions.empty?
34
+
35
+ questions.to_h { |key, question_definition| [key, normalize(key, question_definition)] }
36
+ end
37
+
38
+ def normalize(key, question_definition)
39
+ invalid!(key, "must be a Hash") unless question_definition.is_a?(Hash)
40
+ attributes = question_definition.transform_keys(&:to_s)
41
+ type = attributes["type"].to_s
42
+ invalid!(key, "type must be one of choice, score, noul (got #{attributes["type"].inspect})") unless TYPES.key?(type)
43
+
44
+ Question.new(
45
+ type: type,
46
+ instructions: instructions(key, attributes["instructions"]),
47
+ criteria: criteria(key, type, attributes["criteria"])
48
+ )
49
+ end
50
+
51
+ def instructions(key, value)
52
+ case value
53
+ when String
54
+ invalid!(key, "instructions can't be blank") if value.strip.empty?
55
+ value
56
+ when Hash then JSON.generate(value)
57
+ else invalid!(key, "instructions must be a String or Hash")
58
+ end
59
+ end
60
+
61
+ def criteria(key, type, value)
62
+ case type
63
+ when "choice" then choice_criteria(key, value)
64
+ when "score" then score_criteria(key, value)
65
+ else noul_criteria(key, value)
66
+ end
67
+ end
68
+
69
+ def choice_criteria(key, value)
70
+ pairs =
71
+ case value
72
+ when Hash then value.map { |label, desc| [label.to_s, desc&.to_s] }
73
+ when Array then value.map { |label| [label.to_s, nil] }
74
+ else invalid!(key, "choice criteria must be a Hash of label => description or an Array of labels")
75
+ end
76
+ invalid!(key, "choice needs at least 2 options") if pairs.size < 2
77
+ invalid!(key, "choice labels must be unique") if pairs.map(&:first).uniq.size != pairs.size
78
+ pairs.to_h
79
+ end
80
+
81
+ def score_criteria(key, value)
82
+ invalid!(key, "score criteria must be an Array of levels, lowest first") unless value.is_a?(Array)
83
+ invalid!(key, "score needs at least 2 levels") if value.size < 2
84
+ value.map(&:to_s)
85
+ end
86
+
87
+ def noul_criteria(key, value)
88
+ return {} if value.nil?
89
+
90
+ crit = value.is_a?(Hash) ? value.to_h { |k, v| [k.to_s, v&.to_s] } : nil
91
+ invalid!(key, "noul criteria must be a Hash with only true and false keys") unless crit && (crit.keys - %w[true false]).empty?
92
+ crit
93
+ end
94
+
95
+ def invalid!(key, message)
96
+ raise InvalidQuestionError, "question #{key.inspect}: #{message}"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,9 @@
1
+ module Laya
2
+ # Registers the Rake tasks in Rails apps, after the app boots so config/initializers/laya.rb applies.
3
+ class Railtie < Rails::Railtie
4
+ rake_tasks do
5
+ load File.expand_path("tasks.rb", __dir__)
6
+ %w[laya:download laya:path].each { |name| Rake::Task[name].enhance([:environment]) }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,56 @@
1
+ module Laya
2
+ Usage = Data.define(:input_tokens, :output_tokens)
3
+
4
+ ChoiceAnswer = Data.define(:choice, :probabilities, :confidence, :act_probability) do
5
+ def type = "choice"
6
+
7
+ def to_h
8
+ {
9
+ type: type,
10
+ choice: choice,
11
+ probabilities: probabilities,
12
+ confidence: confidence,
13
+ rl_agent: {act_probability: act_probability}
14
+ }
15
+ end
16
+ end
17
+
18
+ ScoreAnswer = Data.define(:score, :probabilities, :legend, :confidence, :act_probability) do
19
+ def type = "score"
20
+
21
+ def to_h
22
+ {
23
+ type: type,
24
+ score: score,
25
+ legend: legend,
26
+ probabilities: probabilities,
27
+ confidence: confidence,
28
+ rl_agent: {act_probability: act_probability}
29
+ }
30
+ end
31
+ end
32
+
33
+ NoulAnswer = Data.define(:noul, :act_probability) do
34
+ def type = "noul"
35
+
36
+ def to_h
37
+ {
38
+ type: type,
39
+ noul: noul,
40
+ rl_agent: {act_probability: act_probability}
41
+ }
42
+ end
43
+ end
44
+
45
+ Result = Data.define(:model, :answers, :usage) do
46
+ def [](key) = answers[key]
47
+
48
+ def to_h
49
+ {
50
+ model: model,
51
+ answers: answers.transform_values(&:to_h),
52
+ usage: usage.to_h
53
+ }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,97 @@
1
+ require "json"
2
+
3
+ module Laya
4
+ module Sequence
5
+ SpecialIds = Data.define(:cls, :sep, :mask, :pad, :mask_token)
6
+ Built = Data.define(:ids, :markers)
7
+
8
+ OPTION_TOKEN_CAP = 48
9
+ MIN_OPTION_BUDGET = 16
10
+ MIN_TOKENS_PER_OPTION = 4
11
+ MIN_HEAD_TOKENS = 8
12
+
13
+ module_function
14
+
15
+ def serialize_state(state) = state.is_a?(String) ? state : model_json(state)
16
+
17
+ # JSON with json.dumps separators (", " and ": "), the format the model was trained on.
18
+ def model_json(value)
19
+ case value
20
+ when Hash then "{" + value.map { |key, item| "#{JSON.generate(key.to_s)}: #{model_json(item)}" }.join(", ") + "}"
21
+ when Array then "[" + value.map { |item| model_json(item) }.join(", ") + "]"
22
+ else JSON.generate(value)
23
+ end
24
+ end
25
+
26
+ def build(encode, special_ids, state, question, max_len:, head_max_len:)
27
+ scrub = ->(text) { text.gsub(special_ids.mask_token, " ") }
28
+
29
+ head_ids = encode.call("#{question.type} question: #{scrub.call(question.instructions)}")
30
+ option_ids = question.options.map do |option|
31
+ [special_ids.mask] + encode.call(" " + scrub.call(option)).first(OPTION_TOKEN_CAP)
32
+ end
33
+
34
+ budget = head_max_len - option_ids.sum(&:size)
35
+ if budget < MIN_OPTION_BUDGET # too many / too long options: shrink every option text evenly
36
+ per_option = [MIN_TOKENS_PER_OPTION, (head_max_len - MIN_OPTION_BUDGET) / [1, option_ids.size].max].max
37
+ option_ids = option_ids.map { |ids| ids.first(per_option) }
38
+ budget = head_max_len - option_ids.sum(&:size)
39
+ end
40
+
41
+ ids = [special_ids.cls, *head_ids.first([MIN_HEAD_TOKENS, budget].max), special_ids.sep]
42
+ markers = option_ids.map { |option| ids.size.tap { ids.concat(option) } }
43
+ ids << special_ids.sep
44
+
45
+ room = [0, max_len - ids.size - 1].max
46
+ ids.concat(encode.call(scrub.call(serialize_state(state))).first(room))
47
+ ids << special_ids.sep
48
+
49
+ Built.new(ids: ids.first(max_len), markers: markers.select { |marker| marker < max_len })
50
+ end
51
+
52
+ def collate(rows, pad:)
53
+ length = rows.map { |built, _| built.ids.size }.max
54
+ width = rows.map { |built, _| built.markers.size }.max
55
+
56
+ {
57
+ input_ids: rows.map { |built, _| built.ids + [pad] * (length - built.ids.size) },
58
+ attention_mask: rows.map { |built, _| [1] * built.ids.size + [0] * (length - built.ids.size) },
59
+ marker_pos: rows.map { |built, _| built.markers + [0] * (width - built.markers.size) },
60
+ marker_mask: rows.map { |built, _| [true] * built.markers.size + [false] * (width - built.markers.size) },
61
+ qtype: rows.map { |_, type_id| type_id }
62
+ }
63
+ end
64
+
65
+ def temp_bucket(type, option_count)
66
+ size =
67
+ if option_count <= 2 then "2"
68
+ elsif option_count <= 5 then "3-5"
69
+ elsif option_count <= 10 then "6-10"
70
+ else "11+"
71
+ end
72
+
73
+ "#{type}:#{size}"
74
+ end
75
+
76
+ def temperature(model_config, type, option_count)
77
+ model_config.fetch("temperature_by_options", {})[temp_bucket(type, option_count)] ||
78
+ model_config.fetch("temperature", [])[Questions::TYPES.fetch(type)] ||
79
+ 1.0
80
+ end
81
+
82
+ def softmax(logits)
83
+ max = logits.max
84
+ exponentials = logits.map { |logit| Math.exp(logit - max) }
85
+ sum = exponentials.sum
86
+
87
+ exponentials.map { |exponential| exponential / sum }
88
+ end
89
+
90
+ def confidence(probabilities)
91
+ return 1.0 if probabilities.size < 2
92
+
93
+ entropy = -probabilities.sum { |probability| probability * Math.log([probability, 1e-12].max) }
94
+ 1 - entropy / Math.log(probabilities.size)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,38 @@
1
+ require "json"
2
+ require "onnxruntime"
3
+
4
+ module Laya
5
+ # The loaded model: laya_config.json, the tokenizer and the ONNX Runtime session.
6
+ class Session
7
+ OUTPUTS = %w[logits act_probs].freeze
8
+
9
+ attr_reader :model_config, :tokenizer
10
+
11
+ def initialize(dir, providers:, session_options: {})
12
+ @model_config = JSON.parse(File.read(File.join(dir, "laya_config.json")))
13
+ @tokenizer = Tokenizer.new(dir)
14
+ options = {graph_optimization_level: :all}.merge(session_options.transform_keys(&:to_sym))
15
+ @onnx = OnnxRuntime::InferenceSession.new(File.join(dir, "laya.onnx"), providers: providers, **options)
16
+ rescue OnnxRuntime::Error, JSON::ParserError, SystemCallError => e
17
+ raise ModelError, "could not load the model from #{dir}: #{e.message}"
18
+ end
19
+
20
+ def special_ids = tokenizer.special_ids
21
+
22
+ def encode(text) = tokenizer.encode(text)
23
+
24
+ def max_len = model_config.fetch("max_len")
25
+
26
+ def head_max_len = model_config.fetch("head_max_len")
27
+
28
+ # inputs: the Hash from Sequence.collate. Returns [logits [B,K], act_probs [B,2]] as nested Arrays.
29
+ def run(inputs)
30
+ logits, act_probs = @onnx.run(OUTPUTS, inputs)
31
+ raise ModelError, "unexpected model outputs (expected logits and act_probs)" unless logits.is_a?(Array) && act_probs.is_a?(Array)
32
+
33
+ [logits, act_probs]
34
+ rescue OnnxRuntime::Error => e
35
+ raise ModelError, "inference failed: #{e.message}"
36
+ end
37
+ end
38
+ end
data/lib/laya/tasks.rb ADDED
@@ -0,0 +1,16 @@
1
+ require "rake"
2
+ require "laya"
3
+
4
+ namespace :laya do
5
+ desc "Download the Laya model files into the cache"
6
+ task :download do
7
+ status = Laya::CLI.new.run(["download"])
8
+ exit(status) unless status.zero?
9
+ end
10
+
11
+ desc "Print the directory holding the Laya model files"
12
+ task :path do
13
+ status = Laya::CLI.new.run(["path"])
14
+ exit(status) unless status.zero?
15
+ end
16
+ end
@@ -0,0 +1,25 @@
1
+ require "tokenizers"
2
+
3
+ module Laya
4
+ # The checkpoint's ModernBERT tokenizer and the special ids the sequence layout needs.
5
+ class Tokenizer
6
+ attr_reader :special_ids
7
+
8
+ def initialize(dir)
9
+ @tokenizer = Tokenizers.from_file(File.join(dir, "tokenizer", "tokenizer.json"))
10
+ @special_ids = Sequence::SpecialIds.new(
11
+ cls: token_id("[CLS]"),
12
+ sep: token_id("[SEP]"),
13
+ mask: token_id("[MASK]"),
14
+ pad: token_id("[PAD]"),
15
+ mask_token: "[MASK]"
16
+ )
17
+ end
18
+
19
+ def encode(text) = @tokenizer.encode(text, add_special_tokens: false).ids
20
+
21
+ private
22
+
23
+ def token_id(token) = @tokenizer.token_to_id(token) || raise(ModelError, "special token #{token} missing from tokenizer")
24
+ end
25
+ end
@@ -0,0 +1,3 @@
1
+ module Laya
2
+ VERSION = "0.1.0"
3
+ end
data/lib/laya.rb ADDED
@@ -0,0 +1,40 @@
1
+ require_relative "laya/version"
2
+ require_relative "laya/errors"
3
+ require_relative "laya/configuration"
4
+ require_relative "laya/questions"
5
+ require_relative "laya/sequence"
6
+ require_relative "laya/result"
7
+ require_relative "laya/tokenizer"
8
+ require_relative "laya/session"
9
+ require_relative "laya/downloader"
10
+ require_relative "laya/client"
11
+ require_relative "laya/cli"
12
+
13
+ module Laya
14
+ class << self
15
+ def config
16
+ @config ||= Configuration.new
17
+ end
18
+
19
+ def configure
20
+ yield config
21
+ config
22
+ end
23
+
24
+ def reset_config!
25
+ @config = Configuration.new
26
+ end
27
+
28
+ # A client with the global config plus overrides. Cheap: the model loads on first use or load!.
29
+ def new(**overrides)
30
+ Client.new(config.merge(**overrides))
31
+ end
32
+
33
+ # Fetch the model files without loading them (Docker builds, CI caches). Returns the directory.
34
+ def download(**overrides)
35
+ Downloader.new(config.merge(**overrides).validate!).call
36
+ end
37
+ end
38
+ end
39
+
40
+ require_relative "laya/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: laya
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eduardo Hernandez
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: onnxruntime
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.11'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.11'
26
+ - !ruby/object:Gem::Dependency
27
+ name: tokenizers
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.7'
40
+ description: Typed decisions (choice, score, noul) with calibrated probabilities in
41
+ one forward pass. A Ruby port of @receptron/laya on top of the onnxruntime and tokenizers
42
+ gems.
43
+ email:
44
+ - eduardoghdez.io@gmail.com
45
+ executables:
46
+ - laya
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - exe/laya
54
+ - lib/laya.rb
55
+ - lib/laya/cli.rb
56
+ - lib/laya/client.rb
57
+ - lib/laya/configuration.rb
58
+ - lib/laya/downloader.rb
59
+ - lib/laya/errors.rb
60
+ - lib/laya/questions.rb
61
+ - lib/laya/railtie.rb
62
+ - lib/laya/result.rb
63
+ - lib/laya/sequence.rb
64
+ - lib/laya/session.rb
65
+ - lib/laya/tasks.rb
66
+ - lib/laya/tokenizer.rb
67
+ - lib/laya/version.rb
68
+ homepage: https://github.com/EduardoGHdez/laya
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ rubygems_mfa_required: 'true'
73
+ source_code_uri: https://github.com/EduardoGHdez/laya
74
+ changelog_uri: https://github.com/EduardoGHdez/laya/blob/main/CHANGELOG.md
75
+ bug_tracker_uri: https://github.com/EduardoGHdez/laya/issues
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '3.3'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 4.0.19
91
+ specification_version: 4
92
+ summary: Run the Laya System-1 decision model from Ruby via ONNX Runtime
93
+ test_files: []