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
data/lib/laya/router.rb
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Laya
|
|
6
|
+
# The upstream checkpoint each name refers to. The gem runs the ONNX export of whichever one is
|
|
7
|
+
# chosen, so a decision reports the checkpoint's own id: the weights are the same.
|
|
8
|
+
BUNDLE_REPO = Checkpoints::BUNDLE_REPO
|
|
9
|
+
DEFAULT_MODELS = {
|
|
10
|
+
"english" => [BUNDLE_REPO, nil].freeze,
|
|
11
|
+
"multilingual" => [BUNDLE_REPO, "multilingual"].freeze,
|
|
12
|
+
"typed-decisions" => [BUNDLE_REPO, "typed-decisions"].freeze
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# The same checkpoints in their own repositories, for anyone who prefers them.
|
|
16
|
+
STANDALONE_MODELS = {
|
|
17
|
+
"english" => "convaiinnovations/laya",
|
|
18
|
+
"multilingual" => "convaiinnovations/laya-multilingual",
|
|
19
|
+
"typed-decisions" => "convaiinnovations/laya-typed-decisions"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
MODEL_ALIASES = Checkpoints::ALIASES
|
|
23
|
+
|
|
24
|
+
# The question-id signatures of the four typed-decisions workflows, used only when
|
|
25
|
+
# `auto_task_detection` is on.
|
|
26
|
+
TYPED_DECISION_WORKFLOWS = {
|
|
27
|
+
"agent_trace_observability" => Set.new(%w[action needs_review outcome risk urgency]).freeze,
|
|
28
|
+
"customer_service" => Set.new(%w[action category churn_risk needs_human urgency]).freeze,
|
|
29
|
+
"invoice_processing" => Set.new(%w[discrepancy_severity disposition duplicate matches_order urgency]).freeze,
|
|
30
|
+
"security_incidents" => Set.new(%w[credential_compromise disposition severity true_positive urgency]).freeze
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Language subtags that mean "the English checkpoint can read this". Routing needs one bit, not
|
|
34
|
+
# a language id, so every other code resolves to the multilingual checkpoint.
|
|
35
|
+
ENGLISH_SUBTAGS = %w[en eng english].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def normalise_name(name) = Checkpoints.normalise(name)
|
|
39
|
+
def normalize_name(name) = Checkpoints.normalise(name)
|
|
40
|
+
def repo_str(spec) = Checkpoints.repo_str(spec)
|
|
41
|
+
|
|
42
|
+
def split_model_spec(spec)
|
|
43
|
+
spec.is_a?(Array) ? [spec[0], spec[1]] : [spec, nil]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The workflow whose question ids these are, or nil. An exact match is required, so a schema
|
|
47
|
+
# that merely contains "urgency" is never captured.
|
|
48
|
+
def match_typed_decisions_workflow(questions)
|
|
49
|
+
ids = Set.new((questions || {}).keys.map(&:to_s))
|
|
50
|
+
TYPED_DECISION_WORKFLOWS.find { |_name, signature| ids == signature }&.first
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether a language code says the English checkpoint can read the text: true, false, or nil
|
|
54
|
+
# when the code identifies nothing.
|
|
55
|
+
#
|
|
56
|
+
# Accepts `"en"`, `"EN"`, `"en-US"`, the POSIX `"en_US"` and `"en_US.UTF-8"`. Nil is not a
|
|
57
|
+
# verdict but the absence of one, which is what lets a language identifier abstain and the
|
|
58
|
+
# router fall through to its own detection. It is deliberately not a predicate: a `?` method
|
|
59
|
+
# that answers nil is a trap for the caller, and for anything that "simplifies" it later.
|
|
60
|
+
def english_language_hint(value)
|
|
61
|
+
return nil if value.nil?
|
|
62
|
+
|
|
63
|
+
code = value.to_s.strip.downcase.split(".", 2).first.to_s
|
|
64
|
+
primary = code.tr("_", "-").split("-", 2).first.to_s
|
|
65
|
+
return nil if primary.empty?
|
|
66
|
+
|
|
67
|
+
ENGLISH_SUBTAGS.include?(primary)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Why a request went to the checkpoint it did.
|
|
72
|
+
class RouteDecision
|
|
73
|
+
attr_reader :model, :repo, :reason, :detection, :workflow
|
|
74
|
+
|
|
75
|
+
def initialize(model:, repo:, reason:, detection: nil, workflow: nil)
|
|
76
|
+
@model = model
|
|
77
|
+
@repo = repo
|
|
78
|
+
@reason = reason
|
|
79
|
+
@detection = detection
|
|
80
|
+
@workflow = workflow
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The payload upstream's Python puts under "routing".
|
|
84
|
+
def to_h
|
|
85
|
+
{ "model" => model, "repo" => repo, "reason" => reason,
|
|
86
|
+
"detection" => detection, "workflow" => workflow }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_json(*) = to_h.to_json(*)
|
|
90
|
+
|
|
91
|
+
def inspect
|
|
92
|
+
"#<Laya::RouteDecision #{model.inspect} #{reason.inspect}>"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Sends each request to the checkpoint best suited to it.
|
|
97
|
+
#
|
|
98
|
+
# router = Laya::Router.new
|
|
99
|
+
# router.predict({ "message" => "Mein Konto wurde zweimal belastet" }, questions) # multilingual
|
|
100
|
+
# router.predict({ "message" => "I was charged twice" }, questions) # english
|
|
101
|
+
# router.predict(state, questions, model: "typed-decisions") # explicit
|
|
102
|
+
#
|
|
103
|
+
# Checkpoints are downloaded and built on first use, and `max_loaded` caps how many stay
|
|
104
|
+
# resident. The default of two is what automatic routing needs: it only ever chooses between
|
|
105
|
+
# english and multilingual, and a cap of one would rebuild the checkpoint it just evicted on
|
|
106
|
+
# every script switch. Raise it to three, or preload, when `typed-decisions` is also in play.
|
|
107
|
+
#
|
|
108
|
+
# router = Laya::Router.new(preload: true) # everything resident, routing is free
|
|
109
|
+
# router.preload(["english", "multilingual"]) # or just the two you serve
|
|
110
|
+
# router.attach("english", existing_agent) # reuse an agent you already built
|
|
111
|
+
# router.unload # free memory
|
|
112
|
+
class Router
|
|
113
|
+
DEFAULT_MAX_LOADED = 2
|
|
114
|
+
|
|
115
|
+
attr_reader :models, :device, :providers, :token, :default, :auto_task_detection, :lang_guess
|
|
116
|
+
attr_accessor :max_loaded
|
|
117
|
+
|
|
118
|
+
# Builds an {Agent}. Injectable so a test, or an app with its own loading rules, can decide
|
|
119
|
+
# how a checkpoint comes into being.
|
|
120
|
+
DEFAULT_AGENT_FACTORY = lambda do |repo, subfolder: nil, **options|
|
|
121
|
+
Agent.new(repo, subfolder: subfolder, **options)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.open(**)
|
|
125
|
+
router = new(**)
|
|
126
|
+
return router unless block_given?
|
|
127
|
+
|
|
128
|
+
begin
|
|
129
|
+
yield router
|
|
130
|
+
ensure
|
|
131
|
+
router.close
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def initialize(models: nil, device: nil, providers: nil, token: nil, max_loaded: DEFAULT_MAX_LOADED,
|
|
136
|
+
default: "english", auto_task_detection: false, standalone_repos: false,
|
|
137
|
+
preload: false, lang_guess: nil, threads: nil, agent_factory: nil)
|
|
138
|
+
@models = (standalone_repos ? STANDALONE_MODELS : DEFAULT_MODELS).dup
|
|
139
|
+
models&.each { |name, spec| @models[Checkpoints.normalise(name)] = spec }
|
|
140
|
+
@device = device
|
|
141
|
+
@providers = providers
|
|
142
|
+
@threads = threads
|
|
143
|
+
@token = token || Hub.token
|
|
144
|
+
@max_loaded = [1, Integer(max_loaded)].max
|
|
145
|
+
@default = Checkpoints.normalise(default)
|
|
146
|
+
@auto_task_detection = auto_task_detection ? true : false
|
|
147
|
+
# An opt-in hint applied to every request: a language code, or a callable taking the state
|
|
148
|
+
# and returning one (or nil to abstain). Checked before the built-in detection, never
|
|
149
|
+
# before an explicit model, task or lang. This is the seam for a real language model.
|
|
150
|
+
@lang_guess = lang_guess
|
|
151
|
+
@agent_factory = agent_factory || DEFAULT_AGENT_FACTORY
|
|
152
|
+
@agents = {}
|
|
153
|
+
@order = [] # least recently used first
|
|
154
|
+
# Guards the model lifecycle and the LRU bookkeeping. Inference deliberately runs outside
|
|
155
|
+
# it, so concurrent requests share a checkpoint instead of queueing.
|
|
156
|
+
@lock = Monitor.new
|
|
157
|
+
self.preload if preload
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------- loading
|
|
161
|
+
|
|
162
|
+
# The agent for `name`, built on first use. Concurrent callers share one.
|
|
163
|
+
def load(name)
|
|
164
|
+
key = Checkpoints.normalise(name)
|
|
165
|
+
@lock.synchronize do
|
|
166
|
+
if @agents.key?(key)
|
|
167
|
+
touch(key)
|
|
168
|
+
next @agents[key]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
repo, subfolder = Laya.split_model_spec(@models.fetch(key))
|
|
172
|
+
agent = @agent_factory.call(repo, subfolder: subfolder, device: @device, providers: @providers,
|
|
173
|
+
token: @token, threads: @threads)
|
|
174
|
+
@agents[key] = agent
|
|
175
|
+
@order << key
|
|
176
|
+
evict
|
|
177
|
+
agent
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Register an already-built agent instead of loading a second copy.
|
|
182
|
+
def attach(name, agent)
|
|
183
|
+
key = Checkpoints.normalise(name)
|
|
184
|
+
@lock.synchronize do
|
|
185
|
+
@agents[key] = agent
|
|
186
|
+
touch(key)
|
|
187
|
+
@max_loaded = [@max_loaded, @agents.length].max
|
|
188
|
+
end
|
|
189
|
+
agent
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Build checkpoints up front, so no request pays a cold load. `max_loaded` grows to fit both
|
|
193
|
+
# what is requested and what is already resident, so preloading never evicts.
|
|
194
|
+
def preload(names = nil)
|
|
195
|
+
names = (names || @models.keys).map { |name| Checkpoints.normalise(name) }
|
|
196
|
+
@lock.synchronize do
|
|
197
|
+
@max_loaded = [@max_loaded, (names | @agents.keys).length].max
|
|
198
|
+
names.each { |name| load(name) unless @agents.key?(name) }
|
|
199
|
+
end
|
|
200
|
+
self
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Free one checkpoint, or all of them.
|
|
204
|
+
def unload(name = nil)
|
|
205
|
+
@lock.synchronize do
|
|
206
|
+
keys = name ? [Checkpoints.normalise(name)] : @agents.keys
|
|
207
|
+
keys.each do |key|
|
|
208
|
+
agent = @agents.delete(key)
|
|
209
|
+
@order.delete(key)
|
|
210
|
+
agent&.close if agent.respond_to?(:close)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
alias close unload
|
|
216
|
+
|
|
217
|
+
# The resident checkpoints, least recently used first.
|
|
218
|
+
def loaded
|
|
219
|
+
@lock.synchronize { @order.dup }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def agents
|
|
223
|
+
@lock.synchronize { @agents.dup }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------- routing
|
|
227
|
+
|
|
228
|
+
# Decide which checkpoint to use, without loading or running anything.
|
|
229
|
+
#
|
|
230
|
+
# Precedence: explicit `model`, explicit `task`, a detected workflow (opt-in), explicit
|
|
231
|
+
# `lang`, a `lang_guess` hint, detected script and language, then the default.
|
|
232
|
+
def route(state, questions = nil, model: nil, task: nil, lang: nil, lang_guess: nil)
|
|
233
|
+
return decide(model, "explicit model=#{model.inspect}") unless model.nil?
|
|
234
|
+
return decide(task_name(task), "explicit task=#{task.inspect}") unless task.nil?
|
|
235
|
+
|
|
236
|
+
workflow = Laya.match_typed_decisions_workflow(questions || {})
|
|
237
|
+
if workflow && auto_task_detection
|
|
238
|
+
return decide("typed-decisions",
|
|
239
|
+
"question ids match the #{workflow.inspect} typed-decisions workflow",
|
|
240
|
+
workflow: workflow)
|
|
241
|
+
end
|
|
242
|
+
unless lang.nil?
|
|
243
|
+
return decide(checkpoint_for(Laya.english_language_hint(lang)), "explicit lang=#{lang.inspect}",
|
|
244
|
+
workflow: workflow)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
hinted = hinted_decision(state, lang_guess, workflow)
|
|
248
|
+
return hinted if hinted
|
|
249
|
+
|
|
250
|
+
detected(state, workflow)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------- running
|
|
254
|
+
|
|
255
|
+
# Route, then answer every question on the chosen checkpoint. The {Result} carries the
|
|
256
|
+
# decision that was made.
|
|
257
|
+
def predict(state, questions, model: nil, task: nil, lang: nil, lang_guess: nil)
|
|
258
|
+
decision = route(state, questions, model: model, task: task, lang: lang, lang_guess: lang_guess)
|
|
259
|
+
load(decision.model).predict(state, questions).with_routing(decision)
|
|
260
|
+
end
|
|
261
|
+
alias system_one predict
|
|
262
|
+
|
|
263
|
+
def inspect
|
|
264
|
+
"#<Laya::Router loaded=#{loaded.inspect} max_loaded=#{max_loaded} default=#{default.inspect}>"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def decide(name, reason, detection: nil, workflow: nil)
|
|
270
|
+
key = Checkpoints.normalise(name)
|
|
271
|
+
RouteDecision.new(model: key, repo: Checkpoints.repo_str(@models.fetch(key)), reason: reason,
|
|
272
|
+
detection: detection, workflow: workflow)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def task_name(task)
|
|
276
|
+
task.to_s.downcase.tr("-", "_") == "typed_decisions" ? "typed-decisions" : task
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def checkpoint_for(english)
|
|
280
|
+
english ? "english" : "multilingual"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# A caller's hint, per call first and then the one installed on the router. Only a hint that
|
|
284
|
+
# actually answers the question routes; anything else falls through to detection.
|
|
285
|
+
def hinted_decision(state, per_call, workflow)
|
|
286
|
+
[["lang_guess", per_call], ["Router(lang_guess=...)", lang_guess]].each do |source, hint|
|
|
287
|
+
english = resolve_hint(hint, state)
|
|
288
|
+
next if english.nil?
|
|
289
|
+
|
|
290
|
+
return decide(checkpoint_for(english),
|
|
291
|
+
"#{source}: the caller identified this as #{english ? 'English' : 'non-English'} text",
|
|
292
|
+
workflow: workflow)
|
|
293
|
+
end
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def resolve_hint(hint, state)
|
|
298
|
+
return nil if hint.nil?
|
|
299
|
+
|
|
300
|
+
Laya.english_language_hint(hint.respond_to?(:call) ? hint.call(state) : hint)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def detected(state, workflow)
|
|
304
|
+
detection = Lang.analyse(state)
|
|
305
|
+
name, reason = read_detection(detection)
|
|
306
|
+
decide(name, reason, detection: detection, workflow: workflow)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def read_detection(detection)
|
|
310
|
+
if detection["script"] == "unknown"
|
|
311
|
+
[default, "no letters detected in state; using default (#{default})"]
|
|
312
|
+
elsif detection["script"] != "latin"
|
|
313
|
+
["multilingual", format("non-Latin script (%s, %.0f%% of letters); the English checkpoint " \
|
|
314
|
+
"cannot read it", detection["script"], 100 * detection["non_latin_fraction"])]
|
|
315
|
+
elsif !detection["is_english"]
|
|
316
|
+
["multilingual", non_english_reason(detection)]
|
|
317
|
+
elsif detection["language_undecided"]
|
|
318
|
+
# Nothing identifies the language: too short, or only content words. That is no evidence
|
|
319
|
+
# of English either, so it takes the same default as a state with no letters at all.
|
|
320
|
+
[default, "Latin script, language not identified and no non-English letters; " \
|
|
321
|
+
"using default (#{default})"]
|
|
322
|
+
else
|
|
323
|
+
["english", "English Latin text"]
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def non_english_reason(detection)
|
|
328
|
+
if detection["language"]
|
|
329
|
+
return "Latin script but language looks like #{detection['language'].inspect}, not English"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# An unidentified Latin-script language, routed on its non-English letters alone because no
|
|
333
|
+
# stopword list here covers it.
|
|
334
|
+
format("Latin script, language not identified but %.0f%% non-English letters; not safe for " \
|
|
335
|
+
"the English checkpoint", 100 * detection["diacritic_rate"])
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def touch(key)
|
|
339
|
+
@order.delete(key)
|
|
340
|
+
@order << key
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def evict
|
|
344
|
+
while @order.length > @max_loaded
|
|
345
|
+
agent = @agents.delete(@order.shift)
|
|
346
|
+
agent&.close if agent.respond_to?(:close)
|
|
347
|
+
end
|
|
348
|
+
return unless @order.length < @agents.length
|
|
349
|
+
|
|
350
|
+
(@agents.keys - @order).each { |key| @agents.delete(key)&.then { |a| a.close if a.respond_to?(:close) } }
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
data/lib/laya/runtime.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "onnxruntime"
|
|
4
|
+
|
|
5
|
+
module Laya
|
|
6
|
+
# The ONNX Runtime session behind an {Agent}: the published decision model, loaded once.
|
|
7
|
+
#
|
|
8
|
+
# The graph exposes three outputs and ONNX Runtime computes only the ones asked for, so
|
|
9
|
+
# answering questions never pays for the encoder states the embedding shortlist needs.
|
|
10
|
+
class Runtime
|
|
11
|
+
LOGITS = "logits"
|
|
12
|
+
ACT_LOGITS = "act_logits"
|
|
13
|
+
HIDDEN = "last_hidden_state"
|
|
14
|
+
INPUTS = %w[input_ids attention_mask marker_pos marker_mask qtype].freeze
|
|
15
|
+
|
|
16
|
+
# Execution providers per device name, most specific first. An unavailable provider is
|
|
17
|
+
# skipped by ONNX Runtime with a warning, so naming one is never fatal.
|
|
18
|
+
DEVICES = {
|
|
19
|
+
"cpu" => ["CPUExecutionProvider"],
|
|
20
|
+
"coreml" => %w[CoreMLExecutionProvider CPUExecutionProvider],
|
|
21
|
+
"mps" => %w[CoreMLExecutionProvider CPUExecutionProvider],
|
|
22
|
+
"cuda" => %w[CUDAExecutionProvider CPUExecutionProvider],
|
|
23
|
+
"gpu" => %w[CUDAExecutionProvider CPUExecutionProvider],
|
|
24
|
+
"tensorrt" => %w[TensorrtExecutionProvider CUDAExecutionProvider CPUExecutionProvider],
|
|
25
|
+
"directml" => %w[DmlExecutionProvider CPUExecutionProvider]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Resolve `device:` / `providers:` into a provider list.
|
|
29
|
+
def self.providers_for(device: nil, providers: nil)
|
|
30
|
+
return Array(providers) unless providers.nil?
|
|
31
|
+
return DEVICES.fetch("cpu") if device.nil?
|
|
32
|
+
|
|
33
|
+
DEVICES.fetch(device.to_s.downcase.split(":").first) do
|
|
34
|
+
raise ArgumentError, "unknown device #{device.inspect}; use one of #{DEVICES.keys} " \
|
|
35
|
+
"or pass providers: [\"...ExecutionProvider\"]"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :path, :providers
|
|
40
|
+
|
|
41
|
+
def initialize(path, providers: nil, device: nil, threads: nil)
|
|
42
|
+
@path = path
|
|
43
|
+
@providers = Runtime.providers_for(device: device, providers: providers)
|
|
44
|
+
@session = OnnxRuntime::InferenceSession.new(
|
|
45
|
+
path, providers: @providers, intra_op_num_threads: threads
|
|
46
|
+
)
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Score a batch of questions. Returns `[logits, act_logits]` as nested Arrays of Float.
|
|
51
|
+
def decide(batch)
|
|
52
|
+
run([LOGITS, ACT_LOGITS], batch)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Encoder states for a tokenized batch, `[batch, seq, hidden]`.
|
|
56
|
+
def encode(batch)
|
|
57
|
+
run([HIDDEN], batch).first
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def close
|
|
61
|
+
@session = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def closed?
|
|
65
|
+
@session.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def run(outputs, batch)
|
|
71
|
+
raise Error, "this agent has been closed" if closed?
|
|
72
|
+
|
|
73
|
+
feed = INPUTS.to_h { |name| [name, batch.fetch(name.to_sym)] }
|
|
74
|
+
# ONNX Runtime sessions are thread-safe for inference, but the gem's FFI pointers are not
|
|
75
|
+
# reentrant, so one request at a time per session.
|
|
76
|
+
@mutex.synchronize { @session.run(outputs, feed) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Laya
|
|
4
|
+
# An opt-in embedding shortlist for choice questions with many labels.
|
|
5
|
+
#
|
|
6
|
+
# Options share one `head_max_len` budget, so a large label set leaves only a few tokens per
|
|
7
|
+
# label and they stop being distinguishable. {predict_shortlist} embeds the state and each
|
|
8
|
+
# option with a caller-supplied `embed_fn`, keeps the top `k`, and runs one prediction on that
|
|
9
|
+
# reduced set. `predict` itself is untouched: it still scores every criterion it is given.
|
|
10
|
+
module Shortlist
|
|
11
|
+
DEFAULT_SHORTLIST_K = 20
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# The top `k` labels for `state`.
|
|
16
|
+
#
|
|
17
|
+
# `embed_fn` maps an Array of Strings to one vector per string. It is called once, with the
|
|
18
|
+
# query first and then the options in criteria order, rendered as the model would see them.
|
|
19
|
+
# When `k` is at least the number of labels every label is returned in its original order and
|
|
20
|
+
# `embed_fn` is never called. Ties keep the earlier label, and a zero vector never outranks
|
|
21
|
+
# a label that came before it.
|
|
22
|
+
def shortlist_choice(state, criteria, embed_fn, k: DEFAULT_SHORTLIST_K, instructions: nil)
|
|
23
|
+
rank(state, criteria, embed_fn, k, instructions).labels
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Shortlist every choice question, then predict once.
|
|
27
|
+
#
|
|
28
|
+
# Questions that are not choices, and choices with `k` labels or fewer, are passed through
|
|
29
|
+
# untouched. The caller's Hash is never mutated. The result carries a `shortlist` entry
|
|
30
|
+
# recording, per question, the labels kept in rank order, their cosine scores, `k`, the
|
|
31
|
+
# original label count `n`, and whether it was a pass-through.
|
|
32
|
+
def predict_shortlist(agent, state, questions, embed_fn, k: DEFAULT_SHORTLIST_K, **predict_options)
|
|
33
|
+
raise TypeError, "questions must be a Hash of question id => definition" unless questions.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
checked = Util.positive_int!(k, "k")
|
|
36
|
+
reduced = {}
|
|
37
|
+
meta = {}
|
|
38
|
+
questions.each do |id, definition|
|
|
39
|
+
unless choice?(definition)
|
|
40
|
+
reduced[id] = definition
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
ranking = rank_question(id, definition, state, embed_fn, checked)
|
|
45
|
+
meta[id] = ranking.to_h
|
|
46
|
+
reduced[id] = ranking.passthrough ? definition : narrow(definition, ranking.labels)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attach(predict_with(agent, state, reduced, **predict_options), meta)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# An `embed_fn` backed by the checkpoint already loaded on `agent`.
|
|
53
|
+
#
|
|
54
|
+
# It mean-pools the encoder, which costs nothing extra but is not a retriever. Measured on
|
|
55
|
+
# Banking77's 77 labels it keeps the right label in the top 20 about as often as picking 20
|
|
56
|
+
# labels at random would: mean-pooled states of this encoder sit within a couple of hundredths
|
|
57
|
+
# of each other in cosine, so the ranking carries little signal. Centering the batch widens the
|
|
58
|
+
# spread without improving recall.
|
|
59
|
+
#
|
|
60
|
+
# Pass a real bi-encoder as `embed_fn` when the shortlist has to be right. This helper is a
|
|
61
|
+
# starting point for callers who have nothing else loaded, and worth measuring on your own
|
|
62
|
+
# labels before relying on it.
|
|
63
|
+
def embed_fn_from_agent(agent, max_length: nil, batch_size: 32)
|
|
64
|
+
Util.positive_int!(max_length, "max_length") unless max_length.nil?
|
|
65
|
+
Util.positive_int!(batch_size, "batch_size")
|
|
66
|
+
|
|
67
|
+
->(texts) { agent.embed(texts, max_length: max_length, batch_size: batch_size) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# What ranking a question produced.
|
|
71
|
+
Ranking = Struct.new(:labels, :scores, :passthrough, :n, :k, keyword_init: true) do
|
|
72
|
+
def to_h
|
|
73
|
+
{ "labels" => labels.dup, "scores" => scores, "k" => k, "n" => n, "passthrough" => passthrough }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def choice?(definition)
|
|
78
|
+
definition.is_a?(Hash) && Util.get(definition, "type").to_s == "choice"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def rank_question(id, definition, state, embed_fn, k)
|
|
82
|
+
unless Util.key?(definition, "criteria")
|
|
83
|
+
raise ArgumentError, "question #{id.inspect} is a choice but has no criteria"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
rank(state, Util.get(definition, "criteria"), embed_fn, k, Util.get(definition, "instructions"))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def rank(state, criteria, embed_fn, k, instructions)
|
|
90
|
+
checked = Util.positive_int!(k, "k")
|
|
91
|
+
items = criteria_items(criteria)
|
|
92
|
+
labels = items.map(&:first)
|
|
93
|
+
if checked >= items.length
|
|
94
|
+
return Ranking.new(labels: labels, scores: nil, passthrough: true, n: items.length, k: checked)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
matrix = embeddings(embed_fn, [query_text(state, instructions)] + option_texts(items))
|
|
98
|
+
scores = cosine(matrix.first, matrix.drop(1))
|
|
99
|
+
order = (0...items.length).sort_by { |i| [-scores[i], i] }.first(checked)
|
|
100
|
+
Ranking.new(labels: order.map { |i| labels[i] }, scores: order.map { |i| scores[i] },
|
|
101
|
+
passthrough: false, n: items.length, k: checked)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def criteria_items(criteria)
|
|
105
|
+
items = case criteria
|
|
106
|
+
when Hash then criteria.to_a
|
|
107
|
+
when Array then criteria.map { |label| [label, nil] }
|
|
108
|
+
else raise TypeError, "choice criteria must be a Hash or an Array, got #{criteria.class}"
|
|
109
|
+
end
|
|
110
|
+
raise ArgumentError, "choice criteria must contain at least one option" if items.empty?
|
|
111
|
+
|
|
112
|
+
duplicate = items.map(&:first).tally.find { |_label, count| count > 1 }
|
|
113
|
+
raise ArgumentError, "choice criteria label #{duplicate.first.inspect} is duplicated" if duplicate
|
|
114
|
+
|
|
115
|
+
items
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def option_texts(items)
|
|
119
|
+
Common.render_options({ t: "choice", ins: "", crit: items.to_h }).map(&:to_s)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def query_text(state, instructions)
|
|
123
|
+
body = Common.serialize_state(state)
|
|
124
|
+
return body if instructions.nil? || instructions == ""
|
|
125
|
+
|
|
126
|
+
instructions = PyJSON.dumps(instructions) unless instructions.is_a?(String)
|
|
127
|
+
"#{instructions}\n#{body}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def narrow(definition, labels)
|
|
131
|
+
criteria = Util.get(definition, "criteria")
|
|
132
|
+
kept = criteria.is_a?(Hash) ? labels.to_h { |label| [label, criteria[label]] } : labels.dup
|
|
133
|
+
Util.put(definition.dup, "criteria", kept)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Whatever `embed_fn` returns, as rows of Float with non-finite values zeroed.
|
|
137
|
+
def embeddings(embed_fn, texts)
|
|
138
|
+
raise TypeError, "embed_fn must respond to #call" unless embed_fn.respond_to?(:call)
|
|
139
|
+
|
|
140
|
+
rows = rowify(embed_fn.call(texts.dup))
|
|
141
|
+
unless rows.is_a?(Array) && rows.length == texts.length &&
|
|
142
|
+
rows.all? { |row| row.is_a?(Array) && !row.empty? } && rows.map(&:length).uniq.length <= 1
|
|
143
|
+
raise ArgumentError, "embed_fn must return #{texts.length} vectors of equal width, got #{shape(rows)}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
rows.map { |row| row.map { |value| finite(value) } }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def rowify(raw)
|
|
150
|
+
return raw if raw.is_a?(Array)
|
|
151
|
+
return raw.to_a if raw.respond_to?(:to_a)
|
|
152
|
+
|
|
153
|
+
raw
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def shape(rows)
|
|
157
|
+
return rows.class.to_s unless rows.is_a?(Array)
|
|
158
|
+
return "#{rows.length} values" unless rows.first.is_a?(Array)
|
|
159
|
+
|
|
160
|
+
"#{rows.length} x #{rows.map { |row| row.is_a?(Array) ? row.length : 1 }.uniq.join('|')}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def finite(value)
|
|
164
|
+
number = Float(value)
|
|
165
|
+
number.finite? ? number : 0.0
|
|
166
|
+
rescue ArgumentError, TypeError
|
|
167
|
+
0.0
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Cosine similarity, clipped to [-1, 1] so rounding never reports an impossible score.
|
|
171
|
+
def cosine(query, documents)
|
|
172
|
+
norm = Math.sqrt(query.sum { |value| value * value })
|
|
173
|
+
return Array.new(documents.length, 0.0) if norm.zero? || documents.empty?
|
|
174
|
+
|
|
175
|
+
documents.map do |document|
|
|
176
|
+
length = Math.sqrt(document.sum { |value| value * value })
|
|
177
|
+
next 0.0 unless (length * norm).positive?
|
|
178
|
+
|
|
179
|
+
(document.each_with_index.sum { |value, i| value * query[i] } / (length * norm)).clamp(-1.0, 1.0)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def predict_with(agent, state, questions, **)
|
|
184
|
+
return agent.predict(state, questions, **) if agent.respond_to?(:predict)
|
|
185
|
+
return agent.system_one(state, questions, **) if agent.respond_to?(:system_one)
|
|
186
|
+
|
|
187
|
+
raise TypeError, "agent must respond to #predict or #system_one"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def attach(result, meta)
|
|
191
|
+
return result.with_shortlist(meta) if result.respond_to?(:with_shortlist)
|
|
192
|
+
return result.merge("shortlist" => meta) if result.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
raise TypeError, "predict must return a Laya::Result or a Hash, got #{result.class}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|