bynn-decisionnode 0.4.1

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,539 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DecisionNode
4
+ # The base of every answer. `raw` holds every field the server sent.
5
+ class Answer
6
+ attr_reader :raw, :type
7
+
8
+ # The typed answer for a known type whose fields are all present and of
9
+ # the right type; an UnknownAnswer holding the raw fields otherwise, so a
10
+ # new or malformed answer never fails the decision.
11
+ def self.from(raw)
12
+ return UnknownAnswer.new({ "value" => raw }) unless raw.is_a?(Hash)
13
+
14
+ klass = TYPES[raw["type"]]
15
+ # A point or box question asked with `max` answers a list of places,
16
+ # told by the answer's own list, so a batch line reads the same.
17
+ klass = PLACES[raw["type"]] if PLACES.key?(raw["type"]) && (raw.key?("points") || raw.key?("boxes"))
18
+ klass&.well_formed?(raw) ? klass.new(raw) : UnknownAnswer.new(raw)
19
+ end
20
+
21
+ # Answers by question id, each typed with Answer.from.
22
+ def self.map(answers)
23
+ return {} unless answers.is_a?(Hash)
24
+
25
+ answers.each_with_object({}) { |(id, a), h| h[id] = from(a) }
26
+ end
27
+
28
+ def self.number?(value)
29
+ value.is_a?(Numeric)
30
+ end
31
+
32
+ # A string of decimal digits, as score probabilities and legends are keyed.
33
+ def self.index?(key)
34
+ key.is_a?(String) && key.match?(/\A(0|[1-9][0-9]*)\z/)
35
+ end
36
+
37
+ def initialize(raw)
38
+ @raw = raw
39
+ @type = raw["type"]
40
+ end
41
+
42
+ # A field of the answer as the server sent it.
43
+ def [](key)
44
+ raw[key.to_s]
45
+ end
46
+
47
+ def to_h
48
+ raw
49
+ end
50
+ end
51
+
52
+ # An answer of a type this gem does not know yet. `raw` holds every field the
53
+ # server sent, so a new question type does not break an older client.
54
+ class UnknownAnswer < Answer; end
55
+
56
+ # The answer to a `truth` question. An answer under the API's older name
57
+ # for the type, which a raw question may still use, is read the same way.
58
+ class TruthAnswer < Answer
59
+ OLD_TYPE = "noul"
60
+ private_constant :OLD_TYPE
61
+
62
+ # The probability that the answer is true.
63
+ attr_reader :truth
64
+
65
+ def self.well_formed?(raw)
66
+ number?(raw[value_field(raw)])
67
+ end
68
+
69
+ def self.value_field(raw)
70
+ raw["type"] == OLD_TYPE ? OLD_TYPE : "truth"
71
+ end
72
+
73
+ def initialize(raw)
74
+ super
75
+ @truth = raw[self.class.value_field(raw)]
76
+ end
77
+ end
78
+
79
+ # The answer to a `choice` question.
80
+ class ChoiceAnswer < Answer
81
+ # The chosen option's name, exactly as sent.
82
+ attr_reader :choice
83
+ attr_reader :confidence
84
+ # Probability by option name, exactly as sent.
85
+ attr_reader :probabilities
86
+
87
+ def self.well_formed?(raw)
88
+ probs = raw["probabilities"]
89
+ raw["choice"].is_a?(String) && number?(raw["confidence"]) &&
90
+ probs.is_a?(Hash) && probs.each_value.all? { |v| number?(v) }
91
+ end
92
+
93
+ def initialize(raw)
94
+ super
95
+ @choice = raw["choice"]
96
+ @confidence = raw["confidence"]
97
+ @probabilities = raw["probabilities"].dup
98
+ end
99
+ end
100
+
101
+ # The answer to a `score` question.
102
+ class ScoreAnswer < Answer
103
+ # The expected level, a number from 0 to the number of levels minus one.
104
+ attr_reader :score
105
+ attr_reader :confidence
106
+ # Probability by level index (Integer).
107
+ attr_reader :probabilities
108
+ # Level index (Integer) to the level as it was sent.
109
+ attr_reader :legend
110
+
111
+ def self.well_formed?(raw)
112
+ probs = raw["probabilities"]
113
+ legend = raw["legend"]
114
+ number?(raw["score"]) && number?(raw["confidence"]) &&
115
+ probs.is_a?(Hash) && probs.all? { |k, v| index?(k) && number?(v) } &&
116
+ legend.is_a?(Hash) && legend.each_key.all? { |k| index?(k) }
117
+ end
118
+
119
+ def initialize(raw)
120
+ super
121
+ @score = raw["score"]
122
+ @confidence = raw["confidence"]
123
+ @probabilities = int_keys(raw["probabilities"])
124
+ @legend = int_keys(raw["legend"])
125
+ end
126
+
127
+ private
128
+
129
+ def int_keys(map)
130
+ map.each_with_object({}) { |(k, v), h| h[Integer(k, 10)] = v }
131
+ end
132
+ end
133
+
134
+ # The answer to a `number` question.
135
+ class NumberAnswer < Answer
136
+ # The most probable value of the grid, the JSON number as received.
137
+ attr_reader :number
138
+ # The probability-weighted mean over the grid, as received.
139
+ attr_reader :expected
140
+ # The probability of `number`.
141
+ attr_reader :confidence
142
+ # Probability by grid value, keyed by the value exactly as the server
143
+ # wrote it ("7", "3.5").
144
+ attr_reader :probabilities
145
+
146
+ def self.well_formed?(raw)
147
+ probs = raw["probabilities"]
148
+ number?(raw["number"]) && number?(raw["expected"]) && number?(raw["confidence"]) &&
149
+ probs.is_a?(Hash) && probs.each_value.all? { |v| number?(v) }
150
+ end
151
+
152
+ def initialize(raw)
153
+ super
154
+ @number = raw["number"]
155
+ @expected = raw["expected"]
156
+ @confidence = raw["confidence"]
157
+ @probabilities = raw["probabilities"].dup
158
+ end
159
+ end
160
+
161
+ # What the answers to `point` and `box` questions share: a place in an
162
+ # image. Every number is as received: integers in pixels, fractions when
163
+ # the question asked for normalized units.
164
+ class PlaceAnswer < Answer
165
+ # The probability that what the instructions describe is in the image
166
+ # at all; under a low value the place is only the best guess.
167
+ attr_reader :present
168
+ # The probability that the place is right if the thing is there.
169
+ attr_reader :confidence
170
+ # The id of the image looked in.
171
+ attr_reader :image
172
+ # The image's size in pixels.
173
+ attr_reader :width, :height
174
+
175
+ def self.well_formed?(raw)
176
+ (self::COORDINATES + %w[present confidence width height]).all? { |k| number?(raw[k]) } && raw["image"].is_a?(String)
177
+ end
178
+
179
+ def initialize(raw)
180
+ super
181
+ @present = raw["present"]
182
+ @confidence = raw["confidence"]
183
+ @image = raw["image"]
184
+ @width = raw["width"]
185
+ @height = raw["height"]
186
+ end
187
+ end
188
+
189
+ # The answer to a `point` question: the spot `x`, `y` from the top-left
190
+ # corner of the image, `x` to the right and `y` down.
191
+ class PointAnswer < PlaceAnswer
192
+ COORDINATES = %w[x y].freeze
193
+
194
+ attr_reader :x, :y
195
+
196
+ def initialize(raw)
197
+ super
198
+ @x = raw["x"]
199
+ @y = raw["y"]
200
+ end
201
+ end
202
+
203
+ # The answer to a `box` question: the top-left corner `x1`, `y1` and the
204
+ # bottom-right corner `x2`, `y2`.
205
+ class BoxAnswer < PlaceAnswer
206
+ COORDINATES = %w[x1 y1 x2 y2].freeze
207
+
208
+ attr_reader :x1, :y1, :x2, :y2
209
+
210
+ def initialize(raw)
211
+ super
212
+ @x1 = raw["x1"]
213
+ @y1 = raw["y1"]
214
+ @x2 = raw["x2"]
215
+ @y2 = raw["y2"]
216
+ end
217
+ end
218
+
219
+ private_constant :PlaceAnswer
220
+
221
+ # What the answers to `point` and `box` questions asked with `max` share:
222
+ # up to `max` places, one per thing the instructions describe, in a fixed
223
+ # order (left to right by the centre, ties from the top). Every number is
224
+ # as received.
225
+ class PlacesAnswer < Answer
226
+ # How many places follow, 0 to the question's `max`.
227
+ attr_reader :count
228
+ # The probability that at least one such thing is in the image.
229
+ attr_reader :present
230
+ # The probability that nothing was left out: low when the image holds
231
+ # more than `max`.
232
+ attr_reader :complete
233
+ # The id of the image looked in, and its size in pixels.
234
+ attr_reader :image, :width, :height
235
+
236
+ def self.well_formed?(raw)
237
+ list = raw[self::LIST]
238
+ raw["count"].is_a?(Integer) && !raw["count"].negative? &&
239
+ number?(raw["present"]) && number?(raw["complete"]) &&
240
+ list.is_a?(Array) && list.all? { |p| p.is_a?(Hash) && self::PLACE.members.all? { |k| number?(p[k.to_s]) } } &&
241
+ raw["image"].is_a?(String) && number?(raw["width"]) && number?(raw["height"])
242
+ end
243
+
244
+ def initialize(raw)
245
+ super
246
+ @count = raw["count"]
247
+ @present = raw["present"]
248
+ @complete = raw["complete"]
249
+ @image = raw["image"]
250
+ @width = raw["width"]
251
+ @height = raw["height"]
252
+ @places = raw[self.class::LIST].map { |p| self.class::PLACE.new(*self.class::PLACE.members.map { |k| p[k.to_s] }) }.freeze
253
+ end
254
+ end
255
+
256
+ # The answer to a `point` question asked with `max`: `points`, each a
257
+ # PointsAnswer::Point with `x`, `y` and its own `confidence`.
258
+ class PointsAnswer < PlacesAnswer
259
+ # One spot of the answer.
260
+ Point = Struct.new(:x, :y, :confidence)
261
+ PLACE = Point
262
+ LIST = "points"
263
+
264
+ def points
265
+ @places
266
+ end
267
+ end
268
+
269
+ # The answer to a `box` question asked with `max`: `boxes`, each a
270
+ # BoxesAnswer::Box with `x1`, `y1`, `x2`, `y2` and its own `confidence`.
271
+ class BoxesAnswer < PlacesAnswer
272
+ # One rectangle of the answer.
273
+ Box = Struct.new(:x1, :y1, :x2, :y2, :confidence)
274
+ PLACE = Box
275
+ LIST = "boxes"
276
+
277
+ def boxes
278
+ @places
279
+ end
280
+ end
281
+
282
+ private_constant :PlacesAnswer
283
+
284
+ class Answer
285
+ TYPES = {
286
+ "truth" => TruthAnswer, "noul" => TruthAnswer, "choice" => ChoiceAnswer,
287
+ "score" => ScoreAnswer, "number" => NumberAnswer, "point" => PointAnswer, "box" => BoxAnswer
288
+ }.freeze
289
+ PLACES = { "point" => PointsAnswer, "box" => BoxesAnswer }.freeze
290
+ end
291
+
292
+ # Token counts of a decision and, for a request with videos, the frames the
293
+ # API read (an Integer) and the seconds of video they cover (a number as
294
+ # received); for a request that named boosters, `booster_tokens` (the share
295
+ # of `input_tokens` the boosters' readings take) and `boosters` (the booster
296
+ # calls billed); `cost`, what the answer cost (a Cost). Each is nil when the
297
+ # response has none.
298
+ Usage = Struct.new(:input_tokens, :output_tokens, :video_frames, :video_seconds, :booster_tokens, :boosters,
299
+ :cost)
300
+
301
+ # What an answer cost, Usage#cost: `usd` in all, `model_usd` for the model,
302
+ # `boosters_usd` for the boosters (each the JSON number as received, in USD)
303
+ # and `free_tier` (true when the free tier paid for it). Read as sent: `usd`
304
+ # is the server's figure, never the sum of the parts here.
305
+ Cost = Struct.new(:usd, :model_usd, :boosters_usd, :free_tier) do
306
+ # The `cost` of a response's usage, or nil when it has none. An amount
307
+ # that is not a number, or a `free_tier` that is not true or false, is nil.
308
+ def self.parse(value)
309
+ return nil unless value.is_a?(Hash)
310
+
311
+ amount = ->(v) { v.is_a?(Integer) || v.is_a?(Float) ? v : nil }
312
+ free = value["free_tier"]
313
+ new(amount.call(value["usd"]), amount.call(value["model_usd"]), amount.call(value["boosters_usd"]),
314
+ [true, false].include?(free) ? free : nil)
315
+ end
316
+ end
317
+
318
+ # A fallback answer, Decision#fallback: a request for one model answered by
319
+ # another because no worker of the model asked for was ready at once.
320
+ # `served_model` is the model that answered (also Decision#model), from the
321
+ # `x-decisionnode-fallback` header; `requested_model` the one asked for, from
322
+ # `x-decisionnode-requested-model`: the family id for a family id or an older
323
+ # id, the pinned id for a pinned id (nil when the header is absent).
324
+ Fallback = Struct.new(:served_model, :requested_model)
325
+
326
+ # The result of the safety check that runs on every decision on the hosted API,
327
+ # parsed from the `x-decisionnode-safety` response header.
328
+ class Safety
329
+ # The header value as received.
330
+ attr_reader :header
331
+ # Category name to its number, for example {"harm" => 0.031, "self_harm" => 0.004}.
332
+ attr_reader :scores
333
+ # The categories above the key's thresholds, in header order.
334
+ attr_reader :flagged
335
+ # Why the check did not run, when it did not.
336
+ attr_reader :reason
337
+
338
+ # Parses the header value. Returns nil when there is no header.
339
+ def self.parse(value)
340
+ return nil if value.nil?
341
+
342
+ new(value.to_s.strip)
343
+ end
344
+
345
+ def initialize(header)
346
+ @header = header
347
+ @scores = {}
348
+ @flagged = []
349
+ @reason = nil
350
+ if header.start_with?("unchecked")
351
+ @checked = false
352
+ header.split(";").drop(1).each do |param|
353
+ key, _, val = param.strip.partition("=")
354
+ @reason = val.strip.delete_prefix('"').delete_suffix('"') if key == "reason"
355
+ end
356
+ else
357
+ @checked = true
358
+ header.split(",").each do |member|
359
+ member = member.strip
360
+ next if member.empty?
361
+
362
+ head, *params = member.split(";")
363
+ name, _, num = head.partition("=")
364
+ name = name.strip
365
+ @scores[name] = parse_number(num)
366
+ @flagged << name if params.any? { |p| %w[flagged blocked].include?(p.strip) }
367
+ end
368
+ end
369
+ end
370
+
371
+ def checked
372
+ @checked
373
+ end
374
+ alias checked? checked
375
+
376
+ def harm
377
+ @scores["harm"]
378
+ end
379
+
380
+ def self_harm
381
+ @scores["self_harm"]
382
+ end
383
+
384
+ def [](name)
385
+ @scores[name.to_s]
386
+ end
387
+
388
+ def to_h
389
+ return { "checked" => false, "reason" => reason } unless checked
390
+
391
+ { "checked" => true, "harm" => nil, "self_harm" => nil }.merge(@scores).merge("flagged" => flagged)
392
+ end
393
+
394
+ private
395
+
396
+ def parse_number(text)
397
+ f = Float(text.strip)
398
+ f
399
+ rescue ArgumentError, TypeError
400
+ nil
401
+ end
402
+ end
403
+
404
+ # The answers to one request, with the metadata the server sent with them.
405
+ class Decision
406
+ include DecisionResult
407
+
408
+ # Question id to Answer.
409
+ attr_reader :answers
410
+ # The resolved model version.
411
+ attr_reader :model
412
+ attr_reader :usage
413
+ # From the `x-request-id` header.
414
+ attr_reader :request_id
415
+ # The server's processing time, from the `x-decisionnode-latency-ms` header.
416
+ attr_reader :latency_ms
417
+ # A Safety, or nil when the server sent no safety header.
418
+ attr_reader :safety
419
+ # What the API says it changed while reading the request's videos (a
420
+ # lower rate or resolution), each sentence as sent; empty when it sent none.
421
+ attr_reader :warnings
422
+ # What each booster the request named found, as booster name to
423
+ # Evidence in the request's order; nil when the request named none.
424
+ attr_reader :evidence
425
+ # A Fallback when another model answered in place of the one asked for;
426
+ # nil on an ordinary answer.
427
+ attr_reader :fallback
428
+ # The parsed response body.
429
+ attr_reader :raw
430
+ # The response headers, names in lower case.
431
+ attr_reader :headers
432
+
433
+ def initialize(body, headers = {})
434
+ body = {} unless body.is_a?(Hash)
435
+ @raw = body
436
+ @headers = headers
437
+ @model = body["model"]
438
+ @answers = Answer.map(body["answers"])
439
+ usage = body["usage"].is_a?(Hash) ? body["usage"] : {}
440
+ @usage = Usage.new(usage["input_tokens"], usage["output_tokens"],
441
+ usage["video_frames"].is_a?(Integer) ? usage["video_frames"] : nil,
442
+ usage["video_seconds"].is_a?(Numeric) ? usage["video_seconds"] : nil,
443
+ usage["booster_tokens"].is_a?(Integer) ? usage["booster_tokens"] : nil,
444
+ usage["boosters"].is_a?(Integer) ? usage["boosters"] : nil,
445
+ Cost.parse(usage["cost"]))
446
+ @evidence = Evidence.map(body["evidence"])
447
+ @warnings = body["warnings"].is_a?(Array) ? body["warnings"].grep(String).freeze : [].freeze
448
+ @request_id = headers["x-request-id"]
449
+ @latency_ms = parse_latency(headers["x-decisionnode-latency-ms"])
450
+ @safety = Safety.parse(headers["x-decisionnode-safety"])
451
+ served = headers["x-decisionnode-fallback"]
452
+ @fallback = served.nil? ? nil : Fallback.new(served, headers["x-decisionnode-requested-model"])
453
+ end
454
+
455
+ # The answer to the question with this id.
456
+ def [](id)
457
+ answers.fetch(id.to_s) { raise KeyError, "no answer with the id #{id.to_s.inspect}" }
458
+ end
459
+
460
+ def key?(id)
461
+ answers.key?(id.to_s)
462
+ end
463
+
464
+ private
465
+
466
+ def parse_latency(value)
467
+ return nil if value.nil?
468
+
469
+ f = Float(value.strip)
470
+ f == f.floor ? f.to_i : f
471
+ rescue ArgumentError, TypeError
472
+ nil
473
+ end
474
+ end
475
+
476
+ # A model listed by `GET /v1/models`: a model of the catalogue, or one of
477
+ # the key's workspace's fine-tuned models.
478
+ class ModelInfo
479
+ attr_reader :name, :description, :release_date
480
+ # Every other field of the entry: the model's published limits, the
481
+ # `sessions` and `batch` objects included.
482
+ attr_reader :limits
483
+ attr_reader :raw
484
+ # true for a fine-tuned model; nil for a model of the catalogue.
485
+ attr_reader :fine_tuned
486
+ # A fine-tuned model's base model (its pinned name), else nil.
487
+ attr_reader :base
488
+ # A fine-tuned model's version number (an Integer), else nil.
489
+ attr_reader :adapter_version
490
+ # A fine-tuned model's status: "deployed" (it answers decisions), "ready"
491
+ # (trained, not deployed), or another value as sent; else nil.
492
+ attr_reader :status
493
+ # The prices in USD per million input, output, image and video tokens,
494
+ # as sent; nil when the entry states none.
495
+ attr_reader :price_per_million_input, :price_per_million_output,
496
+ :price_per_million_image, :price_per_million_video
497
+ # The share of the live prices a batch line on this model pays, as sent;
498
+ # nil when the entry states none, as on a fine-tuned model.
499
+ attr_reader :batch_price_factor
500
+
501
+ # The prices an entry states, read into the readers of the same names.
502
+ PRICES = %w[price_per_million_input price_per_million_output price_per_million_image
503
+ price_per_million_video batch_price_factor].freeze
504
+
505
+ def initialize(raw)
506
+ @raw = raw.is_a?(Hash) ? raw : {}
507
+ @name = @raw["name"]
508
+ @description = @raw["description"]
509
+ @release_date = @raw["release_date"]
510
+ @limits = @raw.reject { |k, _| %w[name description release_date].include?(k) }
511
+ @fine_tuned = [true, false].include?(@raw["fine_tuned"]) ? @raw["fine_tuned"] : nil
512
+ @base = @raw["base"].is_a?(String) ? @raw["base"] : nil
513
+ @adapter_version = @raw["adapter_version"].is_a?(Integer) ? @raw["adapter_version"] : nil
514
+ @status = @raw["status"].is_a?(String) ? @raw["status"] : nil
515
+ PRICES.each { |k| instance_variable_set("@#{k}", @raw[k].is_a?(Numeric) ? @raw[k] : nil) }
516
+ end
517
+
518
+ # Whether this is a fine-tuned model.
519
+ def fine_tuned?
520
+ fine_tuned == true
521
+ end
522
+
523
+ # One published limit by name, for example "context_tokens", or a path
524
+ # into a nested one: limit("sessions", "max_per_key").
525
+ def limit(*path)
526
+ limits.dig(*path.map(&:to_s))
527
+ end
528
+
529
+ # The session limits (`max_per_key`, `max_window`, `max_frame_tokens`, ...), or nil.
530
+ def sessions
531
+ limits["sessions"]
532
+ end
533
+
534
+ # The Batch API limits (`requests_per_batch`, `add_bytes`, ...), or nil.
535
+ def batch
536
+ limits["batch"]
537
+ end
538
+ end
539
+ end