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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +207 -0
- data/exe/laya +4 -0
- data/lib/laya/cli.rb +113 -0
- data/lib/laya/client.rb +87 -0
- data/lib/laya/configuration.rb +56 -0
- data/lib/laya/downloader.rb +130 -0
- data/lib/laya/errors.rb +13 -0
- data/lib/laya/questions.rb +99 -0
- data/lib/laya/railtie.rb +9 -0
- data/lib/laya/result.rb +56 -0
- data/lib/laya/sequence.rb +97 -0
- data/lib/laya/session.rb +38 -0
- data/lib/laya/tasks.rb +16 -0
- data/lib/laya/tokenizer.rb +25 -0
- data/lib/laya/version.rb +3 -0
- data/lib/laya.rb +40 -0
- metadata +93 -0
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
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
|
data/lib/laya/client.rb
ADDED
|
@@ -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
|
data/lib/laya/errors.rb
ADDED
|
@@ -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
|
data/lib/laya/railtie.rb
ADDED
|
@@ -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
|
data/lib/laya/result.rb
ADDED
|
@@ -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
|
data/lib/laya/session.rb
ADDED
|
@@ -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
|
data/lib/laya/version.rb
ADDED
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: []
|