ruby-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: c8509810ffb7e73b69215a11ea568de7a28de51794a941a4c340efb959e2ad9c
4
+ data.tar.gz: c07270e4c4cc6c2244615f4efb4ce00c6f98b68ffff9fca9dce0ef300a23a742
5
+ SHA512:
6
+ metadata.gz: e496feecedb524c1ce1dcbe9fe5bc76a5109a8548bef81d3aa717efc387bdeadea70e814605c1b3a099391dab818fae819c3e4042761c350882fab49e42f1ec5
7
+ data.tar.gz: 0b5c382689797f3018ff9bcff42d1fd2737ea3d744c66c9fa0f8151c3e119cf9a65d657f46dccf1d2a8a55dc8394e66ebcfbf069bcebf3a795da0e4a73a229c2
data/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-23
4
+
5
+ First release: a Ruby port of [Laya](https://github.com/NandhaKishorM/laya) 0.3.7.
6
+
7
+ - **Inference on ONNX Runtime.** `Laya.load` and `Laya::Router#predict` run ONNX exports of the
8
+ published checkpoints, so installing the gem needs no Python, no LibTorch and no compiler.
9
+ Exports are reproducible from `tools/export_onnx.py` and are verified against PyTorch before
10
+ they are written.
11
+ - **Declare decisions, do not build hashes.** A `Laya::Decision` subclass declares `choice`,
12
+ `score` and `noul` questions and answers them with readers named after each one, plus a
13
+ predicate for every noul. `Laya.ask(state).noul(...).decide` covers the ones not worth a class,
14
+ and `Laya.configure` sets up the shared client. The shipped question sets are decision classes
15
+ too: `Laya::Guard`, `Laya::Triage`, `Laya::Moderation`, `Laya::EmailTriage`,
16
+ `Laya::RequestRouting`.
17
+ - **Answers as objects.** `predict` returns a `Laya::Result` whose answers read as
18
+ `triage.department == :billing`, `.billing?`, `.probability`, `.score`, `.label`, `.confidence`,
19
+ `triage.churn_risk?`. `to_h` renders the payload upstream's Python returns. Equality reads true
20
+ with the answer on the left only, since `Symbol#==` cannot know about it; `case` takes
21
+ `.to_sym`.
22
+ - **Routing.** `Laya::Router` detects script and language, picks a checkpoint per request, keeps
23
+ two resident by default, and accepts a `lang_guess` hint or a callable for a real language
24
+ identifier. Loading is thread-safe; inference is not serialized behind it.
25
+ - **Pure Ruby around the model.** Language and script detection, email cleaning for English,
26
+ Portuguese and Spanish, the five question presets, the embedding shortlist and the calibration
27
+ arithmetic need no ONNX Runtime at all.
28
+ - **Downloads.** Checkpoints land in the standard Hugging Face cache and honour `HF_HOME`,
29
+ `HF_HUB_CACHE`, `HF_HUB_OFFLINE`, `HF_ENDPOINT` and `HF_TOKEN`.
30
+ - **A measured comparison.** `tools/benchmark.rb` runs Laya, TypeSafe's Jev and general LLMs
31
+ over the same public benchmarks with the same questions, reporting accuracy, calibration,
32
+ latency and cost. Results are in `benchmarks/` and summarized in the README.
33
+ - **Faithfulness.** Over 3000 assertions compare this gem with fixtures recorded from upstream
34
+ Python, and an opt-in suite replays 43 calls across nine languages against the real checkpoints.
35
+
36
+ The raw form is still the floor: `agent.predict(state, questions_hash)` takes and returns exactly
37
+ what upstream's Python does, and the parity suite pins it.
38
+
39
+ Not ported: upstream's `serve.py` HTTP server, and training itself. The scoring arithmetic behind
40
+ training (`Laya::Training`) is included.
41
+
42
+ Known gaps, with the measurements behind them, are in
43
+ [ROADMAP.md](ROADMAP.md): the shortlist's built-in embedder ranks no better than chance, calibration
44
+ is unfitted on the multilingual checkpoint, and label sets in the dozens need a two-stage question.
data/LICENSE ADDED
@@ -0,0 +1,176 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
data/NOTICE ADDED
@@ -0,0 +1,7 @@
1
+ ruby-laya
2
+ Copyright 2026 Valentino Stoll
3
+
4
+ This product is a Ruby port of Laya (https://github.com/NandhaKishorM/laya),
5
+ Copyright Convai Innovations, licensed under the Apache License, Version 2.0.
6
+ The model checkpoints (convaiinnovations/laya and siblings) are published
7
+ separately by Convai Innovations under the same license.
data/README.md ADDED
@@ -0,0 +1,399 @@
1
+ # ruby-laya
2
+
3
+ [codenamev.github.io/ruby-laya](https://codenamev.github.io/ruby-laya) · [Benchmarks](#measured-against-jev-and-general-llms) · [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md)
4
+
5
+ **Multilingual, non-autoregressive System 1 decision engine, for Ruby.** A port of
6
+ [Laya](https://github.com/NandhaKishorM/laya): typed decisions (`choice`, `score`, `noul`) over
7
+ any state (text, email, ticket or JSON document) in a single forward pass, with calibrated
8
+ probabilities and a router that picks the right checkpoint per request. The gem is `ruby-laya`;
9
+ the namespace is `Laya`.
10
+
11
+ No text generation, so nothing to parse and nothing to hallucinate.
12
+
13
+ ```ruby
14
+ result = agent.predict(state, questions)
15
+ result[:department].choice # => "billing"
16
+ result[:department].confidence # => 0.94
17
+ result[:churn_risk].probability # => 0.892
18
+ ```
19
+
20
+ Three checkpoints, and a `Laya::Router` that picks between them per request:
21
+
22
+ | | encoder | params | context | use it for |
23
+ |---|---|---|---|---|
24
+ | [`laya`](https://huggingface.co/convaiinnovations/laya) | ModernBERT-large | 421M | 512 | English |
25
+ | [`laya-multilingual`](https://huggingface.co/convaiinnovations/laya-multilingual) | mmBERT-base | 322M | 1024 | 100+ languages, 2x faster |
26
+ | [`laya-typed-decisions`](https://huggingface.co/convaiinnovations/laya-typed-decisions) | ModernBERT-large | 421M | 1024 | the typed-decisions workflows |
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ bundle add ruby-laya
32
+ ```
33
+
34
+ That is the whole install. Inference runs on [ONNX Runtime](https://onnxruntime.ai) through the
35
+ `onnxruntime` gem, which ships prebuilt binaries, so there is no Python, no LibTorch and no
36
+ compiler involved. Ruby 3.3 or newer.
37
+
38
+ The gem runs [ONNX exports](https://huggingface.co/codenamev/laya-onnx) of the published
39
+ checkpoints, downloaded on first use into the standard Hugging Face cache
40
+ (`HF_HOME`, `HF_HUB_CACHE`, `HF_HUB_OFFLINE` and `HF_TOKEN` all work as usual). The first call
41
+ fetches about 820 MB for the English checkpoint and takes roughly half a minute; after that it is
42
+ cached. The exports are reproducible from [`tools/export_onnx.py`](tools/export_onnx.py), and the
43
+ weights inside them are the ones Convai Innovations published. Point `LAYA_ONNX_REPO` at your own
44
+ repository to serve a mirror or your own fine-tuned export.
45
+
46
+ ## Quickstart
47
+
48
+ Declare the questions you ask often. They are ordinary Ruby, so they live in your app, diff in
49
+ review and can be tested.
50
+
51
+ ```ruby
52
+ require "laya"
53
+
54
+ class TicketTriage < Laya::Decision
55
+ choice :department, "Which team should handle this?",
56
+ billing: "invoices, payments, refunds",
57
+ technical: "bugs, outages, system errors",
58
+ other: "everything else"
59
+
60
+ score :urgency, "How urgent is this?",
61
+ levels: ["not urgent", "soon", "critical deadline"]
62
+
63
+ noul :churn_risk, "Does the customer threaten to cancel?"
64
+ end
65
+ ```
66
+
67
+ Then ask. Every question is answered in one forward pass, on your machine.
68
+
69
+ ```ruby
70
+ triage = TicketTriage.decide(email)
71
+
72
+ triage.department # => #<Laya::Answer::Choice billing 95.9%>
73
+ triage.department == :billing # => true
74
+ triage.department.billing? # => true
75
+ triage.department.confidence # => 0.84
76
+
77
+ triage.urgency.score # => 1.36
78
+ triage.urgency.label # => "soon"
79
+
80
+ triage.churn_risk? # => true
81
+ triage.churn_risk.probability # => 0.827
82
+ ```
83
+
84
+ An answer stands in for its label in a comparison and interpolates as it, while still carrying
85
+ the distribution. One Ruby caveat: equality only reads true with the answer on the left, because
86
+ `Symbol#==` knows nothing about an answer. For the same reason `case` needs the symbol:
87
+
88
+ ```ruby
89
+ triage.department == :billing # => true
90
+ :billing == triage.department # => false, Ruby asks the symbol
91
+
92
+ case triage.department.to_sym
93
+ when :billing then route_to_billing
94
+ when :technical then page_oncall
95
+ end
96
+ ```
97
+
98
+ For a question not worth a class, ask inline. Each call returns the builder, and `decide` runs
99
+ the set:
100
+
101
+ ```ruby
102
+ answers = Laya.ask(email)
103
+ .noul(:refund, "Do they want money back?")
104
+ .choice(:tone, "How does this read?", %w[calm annoyed furious])
105
+ .decide
106
+
107
+ answers.refund.probability # => 0.856
108
+ answers.tone == :annoyed # => true
109
+ ```
110
+
111
+ Set up the process once, if the defaults do not suit:
112
+
113
+ ```ruby
114
+ Laya.configure do |config|
115
+ config.preload = true # every checkpoint resident, no cold start
116
+ config.device = "coreml" # or "cpu", "cuda", "tensorrt", "directml"
117
+ config.model = nil # nil routes per request; name one to pin it
118
+ end
119
+ ```
120
+
121
+ ### The question sets that ship with it
122
+
123
+ ```ruby
124
+ Laya::Guard.decide(prompt).jailbreak? # jailbreaks, injection, harm, topic
125
+ Laya::Triage.decide(message).churn_risk.probability # intent, urgency, frustration, churn
126
+ Laya::Moderation.decide(post).toxic? # toxicity, harassment, threats, spam
127
+ Laya::EmailTriage.decide(Laya.email_state(subject, body, sender: from)).is_phishing?
128
+ Laya::RequestRouting.decide(request).difficulty.score # how hard is this for a model
129
+ ```
130
+
131
+ Each is a `Laya::Decision`, so subclass one to change a label set or pin a checkpoint. The plain
132
+ hashes are still there as `Laya.triage_questions` and friends.
133
+
134
+ ### Routing
135
+
136
+ Script and language detection runs in pure Ruby before any inference, and sends each request to
137
+ the checkpoint that can read it. The English model does not degrade on Devanagari or Han, it
138
+ collapses while staying confident, so the choice has to be made before the forward pass.
139
+
140
+ ```ruby
141
+ triage = TicketTriage.decide(hindi_ticket)
142
+
143
+ triage.routing.model # => "multilingual"
144
+ triage.routing.reason
145
+ # => "non-Latin script (devanagari, 100% of letters); the English checkpoint cannot read it"
146
+
147
+ TicketTriage.decide(ticket, lang: "pt-BR") # when you already know
148
+ ```
149
+
150
+ ```ruby
151
+ class InvoiceCheck < Laya::Decision
152
+ model "typed-decisions" # always this checkpoint
153
+ noul :duplicate, "Is this invoice a duplicate of one already paid?"
154
+ end
155
+ ```
156
+
157
+ ### Keeping checkpoints resident
158
+
159
+ A cold load costs about a second; detection costs microseconds. `max_loaded` defaults to two,
160
+ which is what automatic routing needs, since it only ever chooses between English and
161
+ multilingual.
162
+
163
+ ```ruby
164
+ Laya::Router.new(preload: true) # all three resident
165
+ Laya::Router.new(preload: true, device: "coreml")
166
+ router.preload(["english", "multilingual"]) # or just the two you serve
167
+ router.attach("english", existing_agent) # reuse an agent you already built
168
+ Laya::Router.new(max_loaded: 1) # for a memory-constrained host
169
+ router.unload # free everything
170
+
171
+ Laya.router(preload: true) { |r| r.predict(state, questions) } # closed at the end
172
+ ```
173
+
174
+ `Router#load` is thread-safe: concurrent callers share one agent per checkpoint, and inference
175
+ runs outside the lock, so requests do not queue behind each other.
176
+
177
+ ### A language hint
178
+
179
+ When you already know the language, or have a real language identifier, hand it over and skip the
180
+ heuristic. Returning nil abstains and falls back to detection.
181
+
182
+ ```ruby
183
+ router.predict(state, questions, lang: "pt-BR")
184
+ Laya::Router.new(lang_guess: ->(state) { MyDetector.language_of(state) })
185
+ ```
186
+
187
+ ## One checkpoint, and the raw question form
188
+
189
+ Underneath the DSL, a question set is a Hash and a checkpoint is an object you can hold. This is
190
+ the form the model actually consumes, the one upstream's Python uses, and what the parity suite
191
+ pins; reach for it when you are generating questions, or when you want one checkpoint and no
192
+ router.
193
+
194
+ ```ruby
195
+ agent = Laya.load("convaiinnovations/laya") # English
196
+ agent = Laya.load("convaiinnovations/laya", subfolder: "multilingual") # 100+ languages
197
+ agent = Laya.load("./my-export", device: "coreml") # a local export
198
+
199
+ result = agent.predict(state, {
200
+ "department" => { "type" => "choice", "instructions" => "Which team?",
201
+ "criteria" => { "billing" => "invoices", "technical" => "outages" } }
202
+ })
203
+ result["department"].choice # => "billing"
204
+ agent.close
205
+ ```
206
+
207
+ `TicketTriage.questions` returns exactly this shape, and `Laya::Decision.define(hash)` turns a
208
+ hash back into a decision class. Any object answering `predict(state, questions)` can be passed
209
+ as `client:`, which is how the tests run without a model.
210
+
211
+ `Laya.load` takes `device:` (`"cpu"` by default, plus `"coreml"`, `"cuda"`, `"tensorrt"`,
212
+ `"directml"`), `providers:` for an explicit ONNX Runtime provider list, `threads:`, `token:` and
213
+ `revision:`. Given a block it closes the agent afterwards.
214
+
215
+ ## Confidence gating
216
+
217
+ Probabilities are trained with strictly proper scoring rules, so confidence means something:
218
+
219
+ ```ruby
220
+ if triage.department.confidence >= 0.85
221
+ route_automatically(triage.department.to_sym)
222
+ else
223
+ escalate_to_human(triage, reason: format("low confidence (%.2f)", triage.department.confidence))
224
+ end
225
+ ```
226
+
227
+ Temperatures outside `[0.5, 5.0]` are clamped, with one warning per agent, because a sharpening
228
+ temperature reports a coin flip as a certainty. The English checkpoint ships one such value for
229
+ questions with eleven or more options.
230
+
231
+ ## Decision primitives
232
+
233
+ | Primitive | Declared as | Reads as |
234
+ |---|---|---|
235
+ | **`choice`** | `choice :department, "...", billing: "...", technical: "..."` | `== :billing`, `.billing?`, `.probability("sales")`, `.confidence` |
236
+ | **`score`** | `score :urgency, "...", levels: ["low", "high"]` | `.score`, `.label`, `.probabilities`, `.confidence` |
237
+ | **`noul`** | `noul :churn_risk, "..."` | `triage.churn_risk?`, `.probability`, `.true?(0.9)` |
238
+
239
+ Every answer also carries `.action_probability` from the action head.
240
+
241
+ ## Many labels in one question
242
+
243
+ Options share one token budget, so fifty labels leave only a few tokens each. Either raise the
244
+ budget, split the question, or shortlist with embeddings and run one pass on the top `k`:
245
+
246
+ ```ruby
247
+ result = Laya.predict_shortlist(agent, state, questions, Laya.embed_fn_from_agent(agent), k: 20)
248
+ result.shortlist["intent"]["labels"] # the labels that were kept
249
+ ```
250
+
251
+ `embed_fn` is any callable mapping strings to vectors, so a dedicated bi-encoder drops in.
252
+ `predict` itself is unchanged: it scores every criterion it is given.
253
+
254
+ Use a real bi-encoder. `Laya.embed_fn_from_agent` mean-pools the decision model's own encoder,
255
+ which costs nothing extra but ranks Banking77's labels about as well as choosing at random: the
256
+ pooled states are all within a few hundredths of each other in cosine. It is a starting point, not
257
+ a retriever, and the shortlist is only as good as the embedder behind it.
258
+
259
+ ## Language and script detection
260
+
261
+ ```ruby
262
+ Laya.detect_script("お客様は二重に請求されました") # => "kana"
263
+ Laya.english?("Please refund the duplicate charge") # => true
264
+ Laya.detect_language("Gătește-mi o rețetă")
265
+ # => {"script"=>"latin", "language"=>nil, "is_english"=>false, ...}
266
+ ```
267
+
268
+ Script detection is exact. The Latin-script language guess is a stopword and diacritic heuristic:
269
+ it names a language only on evidence no other language shares, and abstains otherwise.
270
+
271
+ ## Speed against upstream Python
272
+
273
+ Measured on an Apple M5 Max, CPU only, five triage questions on the English checkpoint, against
274
+ upstream Python on the same machine and the same weights.
275
+
276
+ | | ruby-laya (ONNX Runtime) | upstream Python (PyTorch) |
277
+ |---|---|---|
278
+ | Load a checkpoint | 0.7 s | 1.8 s |
279
+ | One question | 44 ms | 77 ms |
280
+ | Five questions | 266 ms | 352 ms |
281
+ | Five questions, multilingual | 114 ms | 141 ms |
282
+
283
+ ## Measured against Jev and general LLMs
284
+
285
+ Laya is compared here with [Jev](https://docs.typesafe.ai) 1.13.0, the hosted decision model whose
286
+ API it mirrors, and with three general models through OpenRouter. Jev gets byte-identical question
287
+ definitions; the LLMs get the same instructions and labels, constrained to the label set with
288
+ structured outputs. Accuracy on four public benchmarks, 500 items each:
289
+
290
+ | | AG News (4 labels) | Emotion (6) | Banking77 (77) | MASSIVE (17, 12 languages) |
291
+ |---|---|---|---|---|
292
+ | **ruby-laya**, local | **0.918** | 0.574 | 0.390 | 0.575 |
293
+ | jev-1.13.0 | 0.866 | 0.574 | **0.808** | **0.868** |
294
+ | qwen3-30b-a3b-instruct | 0.856 | 0.546 | 0.724 | 0.848 |
295
+ | gemini-3.1-flash-lite | 0.826 | 0.550 | 0.784 | 0.848 |
296
+ | gpt-5-nano | 0.710 | 0.566 | 0.710 | 0.799 |
297
+
298
+ | | p50 latency | $ per 1,000 decisions | calibration (ECE) |
299
+ |---|---|---|---|
300
+ | **ruby-laya**, local | **42 to 153 ms** | **$0** | 0.136 to 0.518 |
301
+ | jev-1.13.0 | 208 to 217 ms | $0.018 to $0.071 | 0.070 to 0.267 |
302
+ | the three LLMs | 0.9 to 2.1 s | $0.011 to $0.451 | 0.041 to 0.385 |
303
+
304
+ **Where Laya wins.** On AG News it beats every hosted model, and the margin holds up: a paired
305
+ test over the same 500 items gives p = 0.0005. It answers in a fraction of the time, costs
306
+ nothing per call, keeps the data on your machine, and returns the same answer every time. Jev's
307
+ own answers moved slightly between two runs of this benchmark; Laya's did not.
308
+
309
+ **Where Laya loses, clearly.** With 77 labels it reaches 0.390 against Jev's 0.808, because the
310
+ options share one token budget. Raising that budget and moving to the 1,024-token checkpoint gets
311
+ it to 0.438, so the budget is not the whole story. On multilingual intent it reaches 0.575 against
312
+ Jev's 0.868, and telling the router each item's language only moves it to 0.598, so the gap is the
313
+ checkpoint rather than the routing. Jev handles all twelve languages evenly (0.83 to 0.90) despite
314
+ its documentation calling English primary.
315
+
316
+ **Calibration.** Jev's confidence is better calibrated on three of the four sets. Laya's is worst
317
+ exactly where its accuracy is (ECE 0.518 on Banking77), so confidence gating will not rescue a
318
+ label set that large.
319
+
320
+ Read it as: a fast, free, private model that holds its own on small English label sets, and a
321
+ hosted model that is materially better on hard ones. For emotion, nothing separated any of them.
322
+
323
+ ### Method
324
+
325
+ uv run tools/fetch_benchmark_data.py bench
326
+ TYPESAFE_API_KEY=... OPENROUTER_API_KEY=... ruby tools/benchmark.rb --data bench
327
+
328
+ Full output, including per-language accuracy and token counts, is in
329
+ [`benchmarks/results.json`](benchmarks/results.json). Caveats worth stating: Laya runs locally
330
+ while the others answer over the network from the same machine, so their latency includes the
331
+ round trip; every label is given its own name and a short gloss, which is friendlier to a
332
+ 77-label question than long descriptions would be; samples are 500 items drawn with a fixed seed,
333
+ giving roughly ±4 points at 95% confidence; and the whole run cost $0.54. Upstream's published
334
+ Jev figures come from third parties on different samples, and two of them did not reproduce here:
335
+ Jev scored 0.574 on Emotion against the 0.480 upstream cites, and 0.866 on AG News against 0.910.
336
+
337
+ ## Faithfulness to upstream
338
+
339
+ This port tracks upstream 0.3.7, and that claim is tested rather than asserted:
340
+
341
+ - **Answers.** The tests replay 43 recorded calls, over nine languages and every preset, against
342
+ all three real checkpoints. Each probability, score, confidence and action probability upstream
343
+ reported is reproduced.
344
+ - **Everything around them.** Language detection, email cleaning, routing decisions, option
345
+ rendering, the calibration arithmetic and the shortlist are pinned to fixtures recorded from
346
+ upstream Python, over 3000 assertions worth.
347
+ - **The graph.** The exporter checks ONNX Runtime against PyTorch on inputs the trace never saw,
348
+ and refuses to write an export whose answers differ.
349
+
350
+ Regenerate the fixtures with [`uv`](https://docs.astral.sh/uv/) after an upstream release:
351
+
352
+ ```bash
353
+ bundle exec rake fixtures
354
+ ```
355
+
356
+ ## API map
357
+
358
+ | Python | Ruby |
359
+ |---|---|
360
+ | a `questions` dict | a `Laya::Decision` subclass, or `Laya.ask(state).choice(...)` |
361
+ | `laya.load(...)`, `laya.Agent` | `Laya.load(...)`, `Laya::Agent` |
362
+ | `laya.Router`, `RouteDecision` | `Laya::Router`, `Laya::RouteDecision` |
363
+ | `result["answers"]["x"]["choice"]` | `triage.x == :label`, or `result.to_h` for the same payload |
364
+ | `laya.detect_language / detect_script / is_english` | `Laya.detect_language / detect_script / english?` |
365
+ | `laya.clean_email_body`, `email_state` | `Laya.clean_email_body`, `Laya.email_state` |
366
+ | `laya.*_questions()` | `Laya.*_questions` |
367
+ | `laya.shortlist_choice`, `predict_shortlist`, `embed_fn_from_agent` | the same names on `Laya` |
368
+ | `laya.proper_reward`, `td_lambda_targets` | `Laya::Training` |
369
+ | `with laya.load(...) as agent:` | `Laya.load(...) { |agent| ... }` |
370
+
371
+ Question ids come back as you passed them, symbols included. Upstream's `serve.py` HTTP server is
372
+ not ported; mount `Laya::Router` in your own Rack app.
373
+
374
+ ## Development
375
+
376
+ ```bash
377
+ bundle install
378
+ bundle exec rake test # everything, including the tiny ONNX fixture
379
+ bundle exec rake test_pure # no ONNX Runtime: routing, language, email, shortlist
380
+ bundle exec rubocop
381
+ ```
382
+
383
+ The real-checkpoint test is opt-in, since it needs the 2.3 GB of exports:
384
+
385
+ ```bash
386
+ uv run tools/make_real_fixtures.py <pytorch-checkpoints> real.json
387
+ LAYA_REAL_FIXTURES=real.json bundle exec rake test
388
+ ```
389
+
390
+ ## Contributing
391
+
392
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The short version: the parity fixtures are the contract
393
+ with upstream, so regenerate them rather than editing them, and claims about accuracy or speed
394
+ come with a benchmark run. [ROADMAP.md](ROADMAP.md) lists the measured gaps worth working on.
395
+
396
+ ## License
397
+
398
+ Apache 2.0. Laya was developed by [Convai Innovations](https://huggingface.co/convaiinnovations);
399
+ this port is maintained separately.