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 +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE +176 -0
- data/NOTICE +7 -0
- data/README.md +399 -0
- data/lib/laya/agent.rb +324 -0
- data/lib/laya/ask.rb +73 -0
- data/lib/laya/checkpoints.rb +77 -0
- data/lib/laya/common.rb +175 -0
- data/lib/laya/configuration.rb +72 -0
- data/lib/laya/decision.rb +120 -0
- data/lib/laya/email.rb +187 -0
- data/lib/laya/errors.rb +16 -0
- data/lib/laya/hub.rb +206 -0
- data/lib/laya/lang.rb +301 -0
- data/lib/laya/presets.rb +197 -0
- data/lib/laya/py_json.rb +144 -0
- data/lib/laya/question.rb +171 -0
- data/lib/laya/questions.rb +43 -0
- data/lib/laya/result.rb +263 -0
- data/lib/laya/router.rb +353 -0
- data/lib/laya/runtime.rb +79 -0
- data/lib/laya/shortlist.rb +197 -0
- data/lib/laya/tokenizer.rb +101 -0
- data/lib/laya/training.rb +88 -0
- data/lib/laya/util.rb +57 -0
- data/lib/laya/version.rb +8 -0
- data/lib/laya.rb +129 -0
- metadata +110 -0
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.
|