thinkthen 0.1.2-x86_64-darwin

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 474fac5646049eb988eb169a760b7e27ef7fb97620bbeca216bf8ffc5862b40d
4
+ data.tar.gz: 172d8d17abc80edefb8d7650d2f0196eabefcccbb32fa52d67d97ff706c01538
5
+ SHA512:
6
+ metadata.gz: 9673d005a93a8afc6aa31a33cfa14ee868f2186c79b50a97d808c9776b3f9e455cecc52a6b24f26ad55fd0bfd97167b0a244d7a27a610d3bb2aa1e0f5b60241a
7
+ data.tar.gz: 17a6c75db2d623d331809a694f9f7287b3e80327f73ba777d2b2156e340c4185a0609d0d971b1f9559c7551868f96de492fa6b648821885535c2e88d95dd449c
Binary file
@@ -0,0 +1,3 @@
1
+ module ThinkThen
2
+ VERSION = "0.1.2".freeze
3
+ end
data/lib/thinkthen.rb ADDED
@@ -0,0 +1,725 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Ruby surface over the thinkthen public Rust API.
4
+ #
5
+ # The asking verbs are module methods on ThinkThen, beside question, set,
6
+ # usage, and with_tick. The same methods live on a
7
+ # ThinkThen::Engine value, which carries the engine settings. The module
8
+ # methods call one lazy engine built from the environment.
9
+ #
10
+ # A question is built with ThinkThen.question(decide: "...", threshold:
11
+ # 0.2..0.8), and a verb that takes a question also takes the bare question
12
+ # text. A Range threshold is the band. nil is unsure.
13
+ #
14
+ # Every failure raises a ThinkThen::Error. The six kind classes inherit it,
15
+ # and the binding's own refusals raise UsageError. A record that is not a
16
+ # String crosses as its JSON text, never as Ruby's to_s form.
17
+
18
+ require "json"
19
+ require "timeout"
20
+ require_relative "thinkthen/version"
21
+
22
+ module ThinkThen
23
+ # The one base class. `kind` is the kind's word and `retryable` the
24
+ # engine's retry signal.
25
+ class Error < StandardError
26
+ # Each kind's code in the C door, 1 through 6.
27
+ CODES = { "usage" => 1, "backend" => 2, "deadline" => 3, "local" => 4, "cancelled" => 5, "defect" => 6 }.freeze
28
+
29
+ attr_reader :kind, :retryable, :facts, :details, :completion
30
+
31
+ def initialize(message = nil, kind = nil, retryable = false)
32
+ super(message)
33
+ @kind = kind
34
+ @retryable = retryable
35
+ end
36
+
37
+ def code = CODES.fetch(kind, 6)
38
+ end
39
+
40
+ # A decide answer's code in the C door. ThinkThen.outcome maps an answer to it.
41
+ YES = 1
42
+ NO = 0
43
+ UNSURE = 2
44
+
45
+ class UsageError < Error; end
46
+ class BackendError < Error; end
47
+ class LocalError < Error; end
48
+ class CancelledError < Error; end
49
+ class DeadlineError < Error; end
50
+ class DefectError < Error; end
51
+
52
+ # The old Ruby value plus one completed call's immutable account.
53
+ class Call
54
+ attr_reader :value, :facts, :details
55
+
56
+ def initialize(value, facts, details)
57
+ @value = value
58
+ @facts = ThinkThen.__send__(:deep_freeze, facts)
59
+ @details = ThinkThen.__send__(:deep_freeze, details)
60
+ freeze
61
+ end
62
+
63
+ def map
64
+ self.class.new(yield(value), facts, details)
65
+ rescue Exception => error
66
+ ThinkThen.__send__(:attach_account, error, facts, details)
67
+ raise
68
+ end
69
+
70
+ def inspect
71
+ "#<ThinkThen::Call value=<withheld> records=#{facts[:records]}>"
72
+ end
73
+ end
74
+
75
+ # A prompt stop retains the original worker's eventual final account.
76
+ class Completion
77
+ def initialize(native)
78
+ @native = native
79
+ end
80
+
81
+ def done? = @native.done?
82
+ alias done done?
83
+
84
+ def result(timeout: nil)
85
+ raise UsageError.new("completion timeout takes a nonnegative number", "usage") unless timeout.nil? ||
86
+ (timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout >= 0)
87
+
88
+ raw = @native.result(timeout)
89
+ raise Timeout::Error, "the call has not completed" if raw.nil?
90
+
91
+ @result ||= ThinkThen.__send__(:deep_freeze, raw)
92
+ end
93
+ end
94
+ end
95
+
96
+ require_relative "thinkthen/thinkthen"
97
+
98
+ module ThinkThen
99
+ # The print form of every result value. A field holding the caller's text
100
+ # prints as its byte count, as the Rust Debug and Python repr forms do. A
101
+ # list prints as its length. Places, probabilities, and rule names print
102
+ # in clear.
103
+ module Withheld
104
+ TEXT = %i[name kind text record unit].freeze
105
+
106
+ def inspect
107
+ fields = each_pair.map do |name, value|
108
+ shown = if value.nil? then "nil"
109
+ elsif TEXT.include?(name) then "<#{value.to_s.bytesize} bytes withheld>"
110
+ elsif value.is_a?(Array) then value.size.to_s
111
+ else value.inspect
112
+ end
113
+ "#{name}=#{shown}"
114
+ end
115
+ "#<struct #{self.class.name} #{fields.join(", ")}>"
116
+ end
117
+
118
+ alias to_s inspect
119
+
120
+ def pretty_print(printer)
121
+ printer.text(inspect)
122
+ end
123
+ end
124
+
125
+ # A name and its kind, as relate reads and returns them.
126
+ Entity = Struct.new(:name, :kind) { include Withheld }
127
+
128
+ # One name recognize found. `start`, `end`, and `length` count characters,
129
+ # so `text[start...end]` is the name. `kind` is "ENTITY" when the call
130
+ # named no kind.
131
+ RecognizedEntity = Struct.new(:text, :start, :end, :length, :kind, :strength) { include Withheld }
132
+
133
+ # One relation between two recognized names.
134
+ # `either` is true when the relation holds both ways.
135
+ Relation = Struct.new(:relation, :source, :target, :probability, :either) { include Withheld }
136
+
137
+ # What recognize returned. `relations` is nil when no rule was given.
138
+ Recognized = Struct.new(:entities, :relations) { include Withheld }
139
+
140
+ # One edge relate found, with Entity ends. `either` is true when the edge
141
+ # holds both ways; its ends are then in input order.
142
+ Edge = Struct.new(:relation, :source, :target, :probability, :either) { include Withheld }
143
+
144
+ # The record's place in the input, the record, and its probability.
145
+ # Most likely yes first. Ties keep input order. `to_s` is the record.
146
+ Ranked = Struct.new(:index, :record, :probability) do
147
+ include Withheld
148
+
149
+ def to_s
150
+ record.to_s
151
+ end
152
+ end
153
+
154
+ # The selected unit's place, the unit, and its probability. Every field
155
+ # is nil when nothing is selected.
156
+ Found = Struct.new(:index, :unit, :probability) { include Withheld }
157
+
158
+ # The watchdog's cadence and one in-flight call's row.
159
+ WATCHDOG_INTERVAL = 0.1
160
+ Row = Struct.new(:tick, :token, :error)
161
+ private_constant :WATCHDOG_INTERVAL, :Row, :Native, :Withheld
162
+
163
+ @rows = {}
164
+ @rows_mutex = Mutex.new
165
+ @default_mutex = Mutex.new
166
+
167
+ # An engine and its settings. Omitted settings come from the environment,
168
+ # as the module methods' engine does. The throttle is the most requests
169
+ # in flight at once, per loaded copy of the library.
170
+ class Engine
171
+ def initialize(base_url: nil, model: nil, throttle: nil, max_requests: nil, max_request_bytes: nil, cache: nil,
172
+ timeout: nil, max_retries: nil, record: nil, replay: nil, profile: nil, batch: nil, max_requests_total: nil)
173
+ ThinkThen.__send__(:text_setting, :base_url, base_url)
174
+ ThinkThen.__send__(:text_setting, :model, model)
175
+ ThinkThen.__send__(:whole_setting, :throttle, throttle)
176
+ ThinkThen.__send__(:whole_setting, :max_requests, max_requests)
177
+ ThinkThen.__send__(:whole_setting, :max_request_bytes, max_request_bytes)
178
+ ThinkThen.__send__(:whole_setting, :timeout, timeout)
179
+ ThinkThen.__send__(:whole_setting, :max_retries, max_retries)
180
+ ThinkThen.__send__(:whole_setting, :max_requests_total, max_requests_total)
181
+ { record: record, replay: replay, profile: profile }.each do |name, value|
182
+ ThinkThen.__send__(:text_setting, name, value)
183
+ end
184
+ unless cache.nil? || cache == false || cache.is_a?(String)
185
+ raise UsageError.new("cache is a folder path, false for none, or nil for the default", "usage")
186
+ end
187
+ batch = ThinkThen.__send__(:batch_of, batch)
188
+ @native = Native.engine({ base_url: base_url, model: model, throttle: throttle,
189
+ max_requests: max_requests, max_request_bytes: max_request_bytes,
190
+ cache_at: cache || nil, no_cache: cache == false,
191
+ timeout: timeout, max_retries: max_retries, record: record, replay: replay, profile: profile, batch: batch,
192
+ max_requests_total: max_requests_total })
193
+ end
194
+
195
+ def self.from_native(native)
196
+ engine = allocate
197
+ engine.instance_variable_set(:@native, native)
198
+ engine
199
+ end
200
+ private_class_method :from_native
201
+
202
+ def inspect
203
+ "#<ThinkThen::Engine>"
204
+ end
205
+
206
+ def decide(question, evidence, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
207
+ crossing("decide", ThinkThen.__send__(:built, question), ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
208
+ batch: batch, context: context)
209
+ end
210
+
211
+ def decide_many(question, records, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
212
+ decide_many_with_probabilities(question, records, cancel: cancel, deadline_ms: deadline_ms, batch: batch,
213
+ context: context).map { |rows| rows.map { |row| row[:answer] } }
214
+ end
215
+
216
+ # Each answer with its probability, from the same requests.
217
+ def decide_many_with_probabilities(question, records, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
218
+ crossing("decide_many", ThinkThen.__send__(:built, question), ThinkThen.__send__(:texts, records.to_a), cancel, deadline_ms,
219
+ batch: batch, context: context, batch_ok: true, context_ok: true)
220
+ .map { |rows| rows.map { |answer, probability| { answer: answer, probability: probability } } }
221
+ end
222
+
223
+ def filter(question, records, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
224
+ list = records.to_a
225
+ crossing("filter", ThinkThen.__send__(:built, question), ThinkThen.__send__(:texts, list), cancel, deadline_ms,
226
+ batch: batch, context: context, batch_ok: true, context_ok: true)
227
+ .map { |places| places.map { |place| list[place] } }
228
+ end
229
+
230
+ def rank(question, records, top: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
231
+ list = records.to_a
232
+ crossing("rank", ThinkThen.__send__(:question_text, "rank", question), ThinkThen.__send__(:texts, list), cancel, deadline_ms,
233
+ batch: batch, context: context, batch_ok: true, context_ok: true)
234
+ .map do |placed|
235
+ ranked = placed.map { |place, probability| Ranked.new(place, list[place], probability) }
236
+ top ? ranked.first(top) : ranked
237
+ end
238
+ end
239
+
240
+ # none: true offers a none candidate, as find --none does, so nothing may fit.
241
+ def find(question, units, none: false, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
242
+ raise UsageError.new("none is true or false", "usage") unless [true, false].include?(none)
243
+
244
+ list = units.to_a
245
+ crossing(none ? "find_none" : "find", ThinkThen.__send__(:question_text, "find", question), ThinkThen.__send__(:texts, list), cancel, deadline_ms,
246
+ batch: batch, context: context).map do |place, probability|
247
+ Found.new(place, place.nil? ? nil : list[place], probability)
248
+ end
249
+ end
250
+
251
+ def choose(question, evidence, options: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
252
+ question = ThinkThen.__send__(:keyed, "choose", question, :options, options)
253
+ crossing("details", question, ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
254
+ batch: batch, context: context).map { |json| JSON.parse(json)["value"] }
255
+ end
256
+
257
+ def score(question, evidence, levels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
258
+ question = ThinkThen.__send__(:keyed, "score", question, :levels, levels)
259
+ crossing("score", question, ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
260
+ batch: batch, context: context)
261
+ end
262
+
263
+ # The position and its nearest level, from one call.
264
+ def score_with_level(question, evidence, levels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
265
+ question = ThinkThen.__send__(:keyed, "score", question, :levels, levels)
266
+ crossing("details", question, ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
267
+ batch: batch, context: context).map { |json| JSON.parse(json).then { |doc| [doc["value"], doc.dig("answer", "level")] } }
268
+ end
269
+
270
+ def tag(question, evidence, labels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
271
+ question = ThinkThen.__send__(:keyed, "tag", question, :labels, labels)
272
+ crossing("details", question, ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
273
+ batch: batch, context: context).map { |json| JSON.parse(json)["value"] }
274
+ end
275
+
276
+ # The command's --details document for one text.
277
+ def details(question, evidence, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
278
+ crossing("details", ThinkThen.__send__(:built, question), ThinkThen.__send__(:text_of, evidence), cancel, deadline_ms,
279
+ batch: batch, context: context).map { |json| JSON.parse(json) }
280
+ end
281
+
282
+ def choose_many(question, records, options: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
283
+ many("choose", question, :options, options, records, cancel, deadline_ms, batch, context)
284
+ end
285
+
286
+ def score_many(question, records, levels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
287
+ many("score", question, :levels, levels, records, cancel, deadline_ms, batch, context)
288
+ end
289
+
290
+ def tag_many(question, records, labels: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
291
+ many("tag", question, :labels, labels, records, cancel, deadline_ms, batch, context)
292
+ end
293
+
294
+ # One Hash per record, named by the set's questions. A set member whose
295
+ # `on` names a part reads it from each record as JSON text. With `on:`, each
296
+ # record is a Hash, its `on` value is the evidence, and the answers
297
+ # join its own keys. A question landing on any record's key refuses
298
+ # before any request.
299
+ def annotate(set, records, on: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
300
+ set = ThinkThen.set(set) if set.is_a?(String)
301
+ list = records.to_a
302
+ if on
303
+ ThinkThen.__send__(:refuse_clashes, set, list)
304
+ evidence = list.each_with_index.map { |record, place| ThinkThen.__send__(:text_of, record[on], "record #{place}") }
305
+ else
306
+ evidence = ThinkThen.__send__(:texts, list)
307
+ end
308
+ crossing("annotate", set, evidence, cancel, deadline_ms, batch: batch, context: context, batch_ok: true)
309
+ .map do |rows|
310
+ rows.each_with_index.map do |json, place|
311
+ answers = JSON.parse(json).transform_keys(&:to_sym)
312
+ on ? list[place].transform_keys(&:to_sym).merge(answers) : answers
313
+ end
314
+ end
315
+ end
316
+
317
+ # Find every name in a text and say what kind it is.
318
+ #
319
+ # found = ThinkThen.recognize(text, kinds: %w[person organization place],
320
+ # relations: { works_for: %w[person organization] })
321
+ # found.entities.first.kind # "person"
322
+ def recognize(text, kinds: nil, relations: nil, threshold: nil, relation_threshold: nil, file: nil, cancel: nil, deadline_ms: nil,
323
+ batch: nil, context: nil)
324
+ unless file.nil?
325
+ ThinkThen.__send__(:refuse, "recognize file takes no inline plan options") unless [kinds, relations, threshold, relation_threshold].all?(&:nil?)
326
+ spec = Native.plan_file(ThinkThen.__send__(:file_path, file, "plan"), "recognize")
327
+ else
328
+ spec = JSON.generate(ThinkThen.__send__(:recognize_spec, kinds, relations, threshold, relation_threshold))
329
+ end
330
+ crossing("recognize", spec, ThinkThen.__send__(:text_of, text), cancel, deadline_ms,
331
+ batch: batch, context: context).map { |json| ThinkThen.__send__(:recognized, JSON.parse(json)) }
332
+ end
333
+
334
+ # Say how named entities relate. An entity is a [name, kind] pair, a
335
+ # Hash with name and kind, an Entity, or a RecognizedEntity. A found name,
336
+ # or a Hash with text and no name, is named by its text.
337
+ #
338
+ # edges = ThinkThen.relate([["Ana", "person"], ["Acme", "organization"]],
339
+ # relations: { works_for: %w[person organization] })
340
+ def relate(entities, relations: nil, either: nil, threshold: nil, file: nil, cancel: nil, deadline_ms: nil, batch: nil, context: nil)
341
+ unless file.nil?
342
+ ThinkThen.__send__(:refuse, "relate file takes no inline plan options") unless [relations, either, threshold].all?(&:nil?)
343
+ spec = Native.plan_file(ThinkThen.__send__(:file_path, file, "plan"), "relate")
344
+ else
345
+ ThinkThen.__send__(:refuse, "relate needs at least one relation rule") if relations.nil?
346
+ spec = JSON.generate(ThinkThen.__send__(:relate_spec, relations, either, threshold))
347
+ end
348
+ pairs = entities.to_a.each_with_index.map { |entity, place| ThinkThen.__send__(:pair_of, entity, place) }
349
+ crossing("relate", spec, pairs, cancel, deadline_ms, batch: batch, context: context)
350
+ .map do |rows|
351
+ rows.map do |json|
352
+ edge = JSON.parse(json)
353
+ Edge.new(edge["relation"], ThinkThen.__send__(:entity, edge["source"]), ThinkThen.__send__(:entity, edge["target"]), edge["probability"], edge["either"] == true)
354
+ end
355
+ end
356
+ end
357
+
358
+ # Preview a decide, choose, score, or tag call without asking it: the
359
+ # records, the requests, the prepared bytes, the input-token band, and the
360
+ # first request body, as the result schema's plan object. It reads no key
361
+ # and no cache and sends nothing.
362
+ def plan(question, records, batch: nil, context: nil)
363
+ @native.plan(ThinkThen.__send__(:built, question), ThinkThen.__send__(:texts, records.to_a),
364
+ ThinkThen.__send__(:batch_of, batch), ThinkThen.__send__(:context_of, context))
365
+ end
366
+
367
+ # The engine's counters since the process started.
368
+ def usage
369
+ @native.usage
370
+ end
371
+
372
+ # Set this thread's tick. The watchdog runs it about ten times a second
373
+ # while a call from this thread is in flight. A raise inside it stops the
374
+ # call at once and surfaces from the call. A positional tick with a block
375
+ # scopes the tick to the block's calls.
376
+ def with_tick(tick = nil, &block)
377
+ held = tick || block
378
+ Thread.current[:thinkthen_tick] = held
379
+ return self unless block && tick
380
+
381
+ begin
382
+ yield
383
+ ensure
384
+ Thread.current[:thinkthen_tick] = nil
385
+ end
386
+ end
387
+
388
+ private
389
+
390
+ def many(verb, question, key, members, records, cancel, deadline_ms, batch, context)
391
+ question = ThinkThen.__send__(:keyed, verb, question, key, members)
392
+ crossing("many", question, ThinkThen.__send__(:texts, records.to_a), cancel, deadline_ms,
393
+ batch: batch, context: context, batch_ok: true, context_ok: true)
394
+ .map { |rows| rows.map { |json| JSON.parse(json)["value"] } }
395
+ end
396
+
397
+ def crossing(verb, subject, input, cancel, deadline_ms, batch: nil, context: nil, batch_ok: false, context_ok: false)
398
+ unless cancel.nil? || cancel.is_a?(Cancel)
399
+ raise UsageError.new("cancel is a ThinkThen::Cancel or nil", "usage")
400
+ end
401
+
402
+ raise UsageError.new("#{verb} does not take batch", "usage") if !batch.nil? && !batch_ok
403
+ raise UsageError.new("#{verb} does not take context", "usage") if !context.nil? && !context_ok
404
+ batch = ThinkThen.__send__(:batch_of, batch)
405
+ context = ThinkThen.__send__(:context_of, context)
406
+
407
+ deadline_ms = ThinkThen.__send__(:deadline_ms_of, deadline_ms)
408
+ own = Cancel.new
409
+ tick = Thread.current[:thinkthen_tick]
410
+ row = tick && ThinkThen.__send__(:watch, Row.new(tick, own, nil))
411
+ pending = nil
412
+ begin
413
+ raw, facts, details = @native.call(verb, subject, input, own, cancel, deadline_ms, batch, context)
414
+ Call.new(raw, facts, details)
415
+ rescue Interrupt => error
416
+ pending = error
417
+ stopped = CancelledError.new("the call was cancelled", "cancelled")
418
+ ThinkThen.__send__(:attach_receipt, stopped, error)
419
+ raise stopped
420
+ rescue Exception => error
421
+ pending = error
422
+ ThinkThen.__send__(:attach_receipt, error, error)
423
+ raise
424
+ ensure
425
+ if row
426
+ ThinkThen.__send__(:unwatch, row)
427
+ if row.error
428
+ ThinkThen.__send__(:attach_receipt, row.error, pending) if pending
429
+ raise row.error
430
+ end
431
+ end
432
+ end
433
+ end
434
+ end
435
+
436
+ class << self
437
+ # Build a question from the question file's keys: one verb key
438
+ # (decide, choose, score, or tag) and its text, and threshold, options,
439
+ # levels, labels, or meanings. A Range threshold is the band.
440
+ def question(**keywords)
441
+ if keywords.key?(:file)
442
+ raise UsageError.new("question file takes no other question keys", "usage") unless keywords.size == 1
443
+
444
+ return Native.question_file(file_path(keywords[:file], "question"))
445
+ end
446
+
447
+ body = keywords.transform_keys(&:to_s)
448
+ threshold = body["threshold"]
449
+ body["threshold"] = "#{threshold.begin}:#{threshold.end}" if threshold.is_a?(Range)
450
+ Native.question(JSON.generate(body))
451
+ end
452
+
453
+ # A question set: a path to a set file, or questions by name.
454
+ def set(path = nil, **questions)
455
+ return Native.set_file(path.to_s) if path
456
+
457
+ body = { "version" => 1, "questions" => {} }
458
+ questions.each do |name, spec|
459
+ body["questions"][name.to_s] = spec.is_a?(Question) ? JSON.parse(spec.json) : spec
460
+ end
461
+ Native.set_json(JSON.generate(body))
462
+ end
463
+
464
+ %i[decide decide_many decide_many_with_probabilities filter rank find choose choose_many score score_many score_with_level
465
+ tag tag_many details annotate recognize relate plan usage].each do |name|
466
+ define_method(name) { |*args, **keywords, &block| default_engine.public_send(name, *args, **keywords, &block) }
467
+ end
468
+
469
+ def with_tick(tick = nil, &block)
470
+ default_engine.with_tick(tick, &block)
471
+ end
472
+
473
+ # YES, NO, or UNSURE for a decide answer: true, false, or nil.
474
+ def outcome(answer)
475
+ { true => YES, false => NO, nil => UNSURE }.fetch(answer) { refuse("an outcome reads true, false, or nil") }
476
+ end
477
+
478
+ # The failure of one annotate answer, `{ "kind" => ..., "cause" => ... }`,
479
+ # or nil for any value. nil is unresolved, never a failure.
480
+ def failed(member)
481
+ return unless member.is_a?(Hash) && member.size == 1
482
+
483
+ held = member["failed"] || member[:failed]
484
+ held if held.is_a?(Hash)
485
+ end
486
+
487
+ private
488
+
489
+ def deep_freeze(value)
490
+ case value
491
+ when Hash
492
+ value.each { |key, held| deep_freeze(key); deep_freeze(held) }
493
+ when Array
494
+ value.each { |held| deep_freeze(held) }
495
+ end
496
+ value.freeze
497
+ end
498
+
499
+ def batch_of(value)
500
+ return nil if value.nil?
501
+ return value if value == "max" || (value.is_a?(Integer) && value.positive?)
502
+
503
+ refuse("batch takes max or a whole number of at least 1")
504
+ end
505
+
506
+ def context_of(value)
507
+ return nil if value.nil?
508
+ refuse("context is nonblank text") unless value.is_a?(String)
509
+ text = text_of(value, "context")
510
+ refuse("context is nonblank text") if text.strip.empty?
511
+
512
+ text
513
+ end
514
+
515
+ def attach_receipt(error, source)
516
+ native = source.instance_variable_get(:@thinkthen_completion)
517
+ receipt = native ? Completion.new(native) : source.respond_to?(:completion) ? source.completion : nil
518
+ error.instance_variable_set(:@completion, receipt) if receipt
519
+ unless error.respond_to?(:completion)
520
+ error.define_singleton_method(:completion) { @completion }
521
+ end
522
+ if error.is_a?(Error)
523
+ deep_freeze(error.facts) if error.facts
524
+ deep_freeze(error.details) if error.details
525
+ end
526
+ end
527
+
528
+ def attach_account(error, facts, details)
529
+ error.instance_variable_set(:@facts, facts)
530
+ error.instance_variable_set(:@details, details)
531
+ error.define_singleton_method(:facts) { @facts } unless error.respond_to?(:facts)
532
+ error.define_singleton_method(:details) { @details } unless error.respond_to?(:details)
533
+ end
534
+
535
+ def default_engine
536
+ @default_mutex.synchronize do
537
+ @default ||= Engine.__send__(:from_native, Native.default_engine)
538
+ end
539
+ end
540
+
541
+ def refuse(message)
542
+ raise UsageError.new(message, "usage")
543
+ end
544
+
545
+ def file_path(path, role)
546
+ refuse("#{role} file path is valid text") unless path.is_a?(String)
547
+ path = path.dup.force_encoding(Encoding::UTF_8) unless path.encoding == Encoding::UTF_8
548
+ refuse("#{role} file path is valid text") unless path.valid_encoding? && !path.include?("\0") && !path.empty?
549
+
550
+ path
551
+ end
552
+
553
+ def text_setting(name, value)
554
+ refuse("#{name} is text or nil") unless value.nil? || value.is_a?(String)
555
+ end
556
+
557
+ def whole_setting(name, value)
558
+ refuse("#{name} is a whole number or nil") unless value.nil? || value.is_a?(Integer)
559
+ end
560
+
561
+ # A deadline is whole milliseconds: nil or -1 for none, 0 for spent. The
562
+ # engine rules on the value; this only keeps it a 64-bit whole number.
563
+ def deadline_ms_of(value)
564
+ return nil if value.nil?
565
+ unless value.is_a?(Integer) && value.bit_length < 64
566
+ refuse("deadline_ms is a whole number of milliseconds, or -1 or nil for no deadline")
567
+ end
568
+
569
+ value
570
+ end
571
+
572
+ # The text one record crosses as: a String unchanged, anything else its
573
+ # JSON text. nil, invalid UTF-8, and a NUL byte refuse.
574
+ def text_of(record, where = "the evidence")
575
+ refuse("#{where} is nil; a record is text or a JSON value") if record.nil?
576
+ text = record.is_a?(String) ? record : JSON.generate(record)
577
+ text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
578
+ refuse("#{where} is not valid UTF-8") unless text.valid_encoding?
579
+ refuse("#{where} holds a NUL byte") if text.include?("\0")
580
+ text
581
+ end
582
+
583
+ def texts(list)
584
+ list.each_with_index.map { |record, place| text_of(record, "record #{place}") }
585
+ end
586
+
587
+ # A built question, or a question's own text with its default cut.
588
+ def built(value)
589
+ return value if value.is_a?(Question)
590
+ return Native.question(JSON.generate({ "decide" => value })) if value.is_a?(String)
591
+
592
+ refuse("a question is a built question or its text")
593
+ end
594
+
595
+ # rank and find read one question text and no rule.
596
+ def question_text(verb, value)
597
+ return value if value.is_a?(String)
598
+ if value.is_a?(Question)
599
+ spec = JSON.parse(value.json)
600
+ refuse("#{verb} takes a decide question") unless spec.key?("decide")
601
+ extra = spec.keys.find { |key| key != "decide" }
602
+ refuse("#{verb} takes a decide question with no #{extra}") if extra
603
+ return spec.fetch("decide")
604
+ end
605
+
606
+ refuse("a question is a built question or its text")
607
+ end
608
+
609
+ # A choose, score, or tag question from its text and members, or a
610
+ # built question that carries its own.
611
+ def keyed(verb, question, key, members)
612
+ if question.is_a?(Question)
613
+ refuse("#{verb}: a built question carries its own #{key}; pass them on the question") if members
614
+
615
+ return question
616
+ end
617
+ body = { verb => question }
618
+ body[key.to_s] = members if members
619
+ Native.question(JSON.generate(body))
620
+ end
621
+
622
+ def refuse_clashes(set, list)
623
+ existing = list.flat_map { |record| record.keys.map(&:to_sym) }.uniq
624
+ clash = (set.names.map(&:to_sym) & existing).first
625
+ return unless clash
626
+
627
+ refuse("annotate cannot add a question named '#{clash}': the input already has a key by that name; rename one")
628
+ end
629
+
630
+ def recognize_spec(kinds, relations, threshold, relation_threshold)
631
+ kinds = [] if kinds.nil?
632
+ kinds = kinds.to_h { |name| [name.to_s, nil] } if kinds.is_a?(Array)
633
+ body = { "kinds" => kinds.to_h { |name, description| [name.to_s, description] } }
634
+ body["relations"] = relation_rules(relations) if relations
635
+ spec = { "version" => 1, "recognize" => body }
636
+ spec["threshold"] = threshold unless threshold.nil?
637
+ spec["relation_threshold"] = relation_threshold unless relation_threshold.nil?
638
+ spec
639
+ end
640
+
641
+ # relations: names (any kind to any kind), a Hash of name to [source,
642
+ # target], or the file's rule Hashes. either: the names that read the
643
+ # same both ways.
644
+ def relate_spec(relations, either, threshold)
645
+ rules = relation_rules(relations)
646
+ both = Array(either).map(&:to_s)
647
+ rules.each { |rule| rule["either"] = true if both.include?(rule["name"]) }
648
+ spec = { "version" => 1, "relate" => { "relations" => rules } }
649
+ spec["threshold"] = threshold unless threshold.nil?
650
+ spec
651
+ end
652
+
653
+ def relation_rules(relations)
654
+ relations.map do |name, ends|
655
+ next name.transform_keys(&:to_s) if name.is_a?(Hash)
656
+ next { "name" => name.to_s, "source" => "*", "target" => "*" } if ends.nil?
657
+
658
+ source, target = ends
659
+ { "name" => name.to_s, "source" => source.to_s, "target" => target.to_s }
660
+ end
661
+ end
662
+
663
+ def pair_of(entity, place)
664
+ name, kind = case entity
665
+ when Entity then [entity.name, entity.kind]
666
+ when RecognizedEntity then [entity.text, entity.kind]
667
+ when Hash then [first_of(entity, :name) || first_of(entity, :text), first_of(entity, :kind)]
668
+ when Array then entity
669
+ else refuse("entity #{place} is a [name, kind] pair, a Hash, or an Entity")
670
+ end
671
+ refuse("entity #{place} needs a name and a kind as text") unless name.is_a?(String) && kind.is_a?(String)
672
+
673
+ [text_of(name, "entity #{place}"), text_of(kind, "entity #{place}")]
674
+ end
675
+
676
+ def first_of(hash, key)
677
+ hash.key?(key) ? hash[key] : hash[key.to_s]
678
+ end
679
+
680
+ def entity(held)
681
+ Entity.new(held["name"], held["kind"])
682
+ end
683
+
684
+ def recognized_entity(held)
685
+ RecognizedEntity.new(*held.values_at("text", "start", "end", "length", "kind", "strength"))
686
+ end
687
+
688
+ def recognized(held)
689
+ relations = held["relations"]&.map do |one|
690
+ Relation.new(one["relation"], recognized_entity(one["source"]), recognized_entity(one["target"]), one["probability"], one["either"] == true)
691
+ end
692
+ Recognized.new(held.fetch("entities").map { |one| recognized_entity(one) }, relations)
693
+ end
694
+
695
+ def watch(row)
696
+ @rows_mutex.synchronize { @rows[row.object_id] = row }
697
+ watchdog
698
+ row
699
+ end
700
+
701
+ def unwatch(row)
702
+ @rows_mutex.synchronize { @rows.delete(row.object_id) }
703
+ end
704
+
705
+ # One thread runs every in-flight call's tick. A tick that raises keeps
706
+ # its error in the row and fires the call's own token, and the call's
707
+ # next wait slice stops it. The thread starts again after a fork.
708
+ def watchdog
709
+ return if @watchdog&.alive?
710
+
711
+ @watchdog = Thread.new do
712
+ Thread.current.name = "thinkthen-watchdog"
713
+ loop do
714
+ sleep WATCHDOG_INTERVAL
715
+ @rows_mutex.synchronize { @rows.values }.each do |row|
716
+ row.tick.call
717
+ rescue Exception => e # rubocop:disable Lint/RescueException
718
+ row.error ||= e
719
+ row.token.cancel
720
+ end
721
+ end
722
+ end
723
+ end
724
+ end
725
+ end
metadata ADDED
@@ -0,0 +1,47 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: thinkthen
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: x86_64-darwin
6
+ authors:
7
+ - Ian Maurer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: The ten thinkthen verbs over the thinkthen engine.
13
+ executables: []
14
+ extensions: []
15
+ extra_rdoc_files: []
16
+ files:
17
+ - lib/thinkthen.rb
18
+ - lib/thinkthen/thinkthen.bundle
19
+ - lib/thinkthen/version.rb
20
+ homepage: https://thinkthen.dev
21
+ licenses:
22
+ - MIT
23
+ metadata:
24
+ source_code_uri: https://github.com/botassembly/thinkthen
25
+ changelog_uri: https://github.com/botassembly/thinkthen/blob/main/CHANGELOG.md
26
+ bug_tracker_uri: https://github.com/botassembly/thinkthen/issues
27
+ rdoc_options: []
28
+ require_paths:
29
+ - lib
30
+ required_ruby_version: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '3.4'
35
+ - - "<"
36
+ - !ruby/object:Gem::Version
37
+ version: '4'
38
+ required_rubygems_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ requirements: []
44
+ rubygems_version: 3.6.9
45
+ specification_version: 4
46
+ summary: The thinkthen surface for Ruby
47
+ test_files: []