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.
@@ -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
@@ -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