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,692 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DecisionNode
4
+ # A booster named with settings, for the `boosters:` argument of #decide,
5
+ # the helpers, #decide_many requests and batch requests. A booster reads the
6
+ # request's images before the decision; its reading comes back in
7
+ # Decision#evidence and the model sees it as evidence.
8
+ #
9
+ # DecisionNode::Booster.new("ai_generated", images: ["c3d4"], require: true)
10
+ #
11
+ # name:: the booster's name, for example "age" or "ai_generated".
12
+ # images:: the ids of the request's images it reads; left out, every image.
13
+ # require:: true: a booster that fails or does not answer in time fails the
14
+ # request with the 529 `booster_unavailable` (an OverloadedError,
15
+ # retried). Left out or false: the decision runs without it.
16
+ # options:: the booster's settings, a Hash.
17
+ #
18
+ # A plain name ("age") or a Hash in the API's wire form is accepted wherever
19
+ # a Booster is. Nothing about boosters is checked before sending: the API
20
+ # answers a malformed list, an unknown name and a booster turned off.
21
+ class Booster
22
+ attr_reader :name, :images, :options
23
+
24
+ def initialize(name, images: nil, require: nil, options: nil)
25
+ @name = name.is_a?(Symbol) ? name.to_s : name
26
+ @images = images.is_a?(Array) ? images.dup.freeze : images
27
+ @require = require
28
+ @options = options.is_a?(Hash) ? options.dup.freeze : options
29
+ end
30
+
31
+ # Whether a failed booster fails the request, as given (nil when not given).
32
+ def require
33
+ @require
34
+ end
35
+
36
+ # The booster's object form: `images`, `require` and `options` only when given.
37
+ def to_h
38
+ wire = { "name" => name }
39
+ wire["images"] = images unless images.nil?
40
+ wire["require"] = @require unless @require.nil?
41
+ wire["options"] = options unless options.nil?
42
+ wire
43
+ end
44
+
45
+ # The booster as it is sent: its name alone when no setting is given, so
46
+ # a list of such boosters is the plain list of names; else #to_h.
47
+ def wire
48
+ images.nil? && @require.nil? && options.nil? ? name : to_h
49
+ end
50
+
51
+ # The wire form as JSON, wherever the Booster sits in a value.
52
+ def to_json(*args)
53
+ wire.to_json(*args)
54
+ end
55
+
56
+ def inspect
57
+ "#<#{self.class.name} #{name.inspect}>"
58
+ end
59
+
60
+ KNOWLEDGE_OPTIONS = %i[query scope match filter top_k max_tokens pinned min_score include_text].freeze
61
+ private_constant :KNOWLEDGE_OPTIONS
62
+
63
+ # The `knowledge` booster, reading the passages of one knowledge base that
64
+ # apply to the request:
65
+ #
66
+ # DecisionNode.knowledge("col_...", scope: "M-42", require: true)
67
+ #
68
+ # collection:: the knowledge base's `col_` id (required).
69
+ # query:: replaces the query built from the questions and the state.
70
+ # scope:: the value of the knowledge base's scope key.
71
+ # match, filter:: metadata conditions, JSON values sent as given.
72
+ # top_k:: the most retrieved passages, pins not counted; 0 reads the pins only.
73
+ # max_tokens:: the passages' token budget, an Integer or "auto".
74
+ # pinned:: false leaves the pinned documents out.
75
+ # min_score:: a similarity floor for passages found by meaning alone.
76
+ # include_text:: true adds each passage's text to the reading (a full-scope key).
77
+ # require:: true: a reading that did not run fails the request.
78
+ #
79
+ # Each option is sent only when given, as given; none is checked here.
80
+ def self.knowledge(collection, require: false, **options)
81
+ unknown = options.keys - KNOWLEDGE_OPTIONS
82
+ raise ArgumentError, "unknown knowledge option(s) #{unknown.join(', ')}" unless unknown.empty?
83
+
84
+ wire = { "collection" => collection }
85
+ KNOWLEDGE_OPTIONS.each { |k| wire[k.to_s] = options[k] unless options[k].nil? }
86
+ new("knowledge", require: require == true ? true : nil, options: wire)
87
+ end
88
+ end
89
+
90
+ class << self
91
+ # See Booster.knowledge.
92
+ def knowledge(collection, require: false, **options)
93
+ Booster.knowledge(collection, require: require, **options)
94
+ end
95
+ end
96
+
97
+ # The status and time of one booster on a request, from the
98
+ # `x-decisionnode-boosters` response header: `status` ("ok", "timeout",
99
+ # "error", "skipped", or another value as sent) and `latency_ms` (an Integer,
100
+ # or nil when the header gave none).
101
+ BoosterStatus = Struct.new(:status, :latency_ms) do
102
+ # The header's value as booster name to BoosterStatus, in the header's
103
+ # order; an empty Hash when there is no header. An entry it cannot read is
104
+ # left out; it never raises.
105
+ def self.parse(value)
106
+ out = {}
107
+ return out unless value.is_a?(String)
108
+
109
+ value.split(",").each do |item|
110
+ name, eq, rest = item.strip.partition("=")
111
+ name = name.strip
112
+ next if eq.empty? || name.empty?
113
+
114
+ status, _, ms = rest.partition(":")
115
+ ms = ms.strip
116
+ out[name] = new(status.strip, ms.match?(/\A[0-9]+\z/) ? Integer(ms, 10) : nil)
117
+ end
118
+ out
119
+ end
120
+ end
121
+
122
+ # What one booster found on a request, an entry of Decision#evidence.
123
+ class Evidence
124
+ # The version of the booster that read, for example "age-v3".
125
+ attr_reader :version
126
+ # "ok", "timeout" (no answer in time), "error", "skipped" (none of the
127
+ # request's images is one it reads), or another value as sent.
128
+ attr_reader :status
129
+ # How long the booster took, in milliseconds, as sent.
130
+ attr_reader :latency_ms
131
+ # The calls of this booster billed on this request, or nil when the server
132
+ # did not send it.
133
+ attr_reader :billed_calls
134
+ # One reading per image the booster was to read: an AgeReading, an
135
+ # AiGeneratedReading or an UnknownReading. A picture it did not read (it
136
+ # timed out or failed) or could not decode (`readable` false) is an
137
+ # UnknownReading holding those fields; `status` says why.
138
+ attr_reader :results
139
+ # The entry as received.
140
+ attr_reader :raw
141
+
142
+ # The `evidence` of a response body as booster name to Evidence, in the
143
+ # body's order, or nil when the body has none.
144
+ def self.map(evidence)
145
+ return nil unless evidence.is_a?(Hash)
146
+
147
+ evidence.each_with_object({}) { |(name, entry), h| h[name] = new(name, entry) }
148
+ end
149
+
150
+ def initialize(booster, raw)
151
+ @raw = raw
152
+ entry = raw.is_a?(Hash) ? raw : {}
153
+ @version = entry["version"].is_a?(String) ? entry["version"] : nil
154
+ @status = entry["status"].is_a?(String) ? entry["status"] : nil
155
+ @latency_ms = entry["latency_ms"].is_a?(Numeric) ? entry["latency_ms"] : nil
156
+ @billed_calls = entry["billed_calls"].is_a?(Integer) ? entry["billed_calls"] : nil
157
+ results = entry["results"].is_a?(Array) ? entry["results"] : []
158
+ @results = results.map { |r| Reading.from(booster, r) }.freeze
159
+ end
160
+
161
+ def to_h
162
+ raw
163
+ end
164
+ end
165
+
166
+ # The base of every booster reading. `raw` holds every field the server sent.
167
+ class Reading
168
+ # The id of the image read, or nil when the server sent none.
169
+ attr_reader :image
170
+ attr_reader :raw
171
+
172
+ # The typed reading for a booster this gem knows, when every field is
173
+ # present and of the right type; an UnknownReading holding the raw fields
174
+ # otherwise, so a new booster or a malformed reading never fails the decision.
175
+ def self.from(booster, raw)
176
+ klass = KINDS[booster]
177
+ return UnknownReading.new({ "value" => raw }) unless raw.is_a?(Hash)
178
+
179
+ klass&.well_formed?(raw) ? klass.new(raw) : UnknownReading.new(raw)
180
+ end
181
+
182
+ def self.number?(value)
183
+ value.is_a?(Numeric)
184
+ end
185
+
186
+ # A whole number of 0 or more: a grid's rows and columns, a picture's sides.
187
+ def self.count?(value)
188
+ value.is_a?(Integer) && !value.negative?
189
+ end
190
+
191
+ # Absent (nil) or of the class given.
192
+ def self.optional?(value, klass)
193
+ value.nil? || value.is_a?(klass)
194
+ end
195
+
196
+ def initialize(raw)
197
+ @raw = raw
198
+ @image = raw.is_a?(Hash) && raw["image"].is_a?(String) ? raw["image"] : nil
199
+ end
200
+
201
+ # A field of the reading as the server sent it.
202
+ def [](key)
203
+ raw.is_a?(Hash) ? raw[key.to_s] : nil
204
+ end
205
+
206
+ def to_h
207
+ raw
208
+ end
209
+ end
210
+
211
+ # A reading of a booster this gem does not know, or one with a field
212
+ # missing or of the wrong type. `raw` holds it as received (a reading that
213
+ # is not an object as `{"value" => reading}`).
214
+ class UnknownReading < Reading; end
215
+
216
+ # A reading of the `age` booster: the age of the largest face in the image.
217
+ # With `faces` 0 the image shows no face and the age fields are nil.
218
+ class AgeReading < Reading
219
+ INTEGER_FIELDS = %w[age_years low high].freeze
220
+ private_constant :INTEGER_FIELDS
221
+
222
+ # How many faces were found; the largest is read.
223
+ attr_reader :faces
224
+ # The age in years, and the range `low` to `high`.
225
+ attr_reader :age_years, :low, :high
226
+ # From 0 to 1.
227
+ attr_reader :confidence
228
+
229
+ def self.well_formed?(raw)
230
+ faces = raw["faces"]
231
+ return false unless raw["image"].is_a?(String) && faces.is_a?(Integer) && !faces.negative?
232
+
233
+ # With no face the age fields are left out; with one, each is required.
234
+ INTEGER_FIELDS.all? { |k| raw[k].is_a?(Integer) || (raw[k].nil? && faces.zero?) } &&
235
+ (number?(raw["confidence"]) || (raw["confidence"].nil? && faces.zero?))
236
+ end
237
+
238
+ def initialize(raw)
239
+ super
240
+ @faces = raw["faces"]
241
+ @age_years = raw["age_years"]
242
+ @low = raw["low"]
243
+ @high = raw["high"]
244
+ @confidence = raw["confidence"]
245
+ end
246
+ end
247
+
248
+ # The likeliest generator of an AiGeneratedReading: its `name` and `probability`.
249
+ TopGenerator = Struct.new(:name, :probability)
250
+
251
+ # A reading of the `ai_generated` booster: how likely the image was made by
252
+ # an image generator, and who signed its Content Credentials (C2PA) if it
253
+ # carries any.
254
+ class AiGeneratedReading < Reading
255
+ # The probability that the image was made by a generator, from 0 to 1.
256
+ attr_reader :generated
257
+ # From 0 to 1.
258
+ attr_reader :confidence
259
+ # "inference" (read from the pixels), "c2pa" (the file's Content
260
+ # Credentials say so), or another value as sent.
261
+ attr_reader :method
262
+ # The likeliest generator, a TopGenerator, when the reading says the image
263
+ # is likely generated; nil otherwise, and always on a "c2pa" reading.
264
+ attr_reader :top_generator
265
+ # Who signed the file's Content Credentials when they are trusted; nil
266
+ # otherwise.
267
+ attr_reader :c2pa_signer
268
+ # "untrusted" when the file carries Content Credentials that validate but
269
+ # are not trusted (they name no signer and change nothing in the reading),
270
+ # another value as sent, or nil when the reading has none.
271
+ attr_reader :c2pa
272
+
273
+ def self.well_formed?(raw)
274
+ top = raw["top_generator"]
275
+ raw["image"].is_a?(String) && number?(raw["generated"]) && number?(raw["confidence"]) &&
276
+ raw["method"].is_a?(String) &&
277
+ # top_generator and c2pa_signer are always sent, null when unset; c2pa
278
+ # only when the file's Content Credentials are not trusted.
279
+ raw.key?("top_generator") && raw.key?("c2pa_signer") &&
280
+ (top.nil? || (top.is_a?(Hash) && top["name"].is_a?(String) && number?(top["probability"]))) &&
281
+ (raw["c2pa_signer"].nil? || raw["c2pa_signer"].is_a?(String)) &&
282
+ (raw["c2pa"].nil? || raw["c2pa"].is_a?(String))
283
+ end
284
+
285
+ def initialize(raw)
286
+ super
287
+ @generated = raw["generated"]
288
+ @confidence = raw["confidence"]
289
+ @method = raw["method"]
290
+ top = raw["top_generator"]
291
+ @top_generator = top && TopGenerator.new(top["name"], top["probability"])
292
+ @c2pa_signer = raw["c2pa_signer"]
293
+ @c2pa = raw["c2pa"]
294
+ end
295
+ end
296
+
297
+ # A reading of the `ocr` booster: the text the image shows, in reading
298
+ # order, or the answer to the booster's `prompt` option.
299
+ class OcrReading < Reading
300
+ # The text read; empty when the image shows none, which is a reading, not
301
+ # a failure.
302
+ attr_reader :text
303
+ # true when the reading was cut at the model's context limit.
304
+ attr_reader :truncated
305
+
306
+ def self.well_formed?(raw)
307
+ raw["image"].is_a?(String) && raw["text"].is_a?(String) &&
308
+ [true, false].include?(raw["truncated"])
309
+ end
310
+
311
+ def initialize(raw)
312
+ super
313
+ @text = raw["text"]
314
+ @truncated = raw["truncated"]
315
+ end
316
+ end
317
+
318
+ # A face's box: its corners `x1`, `y1` (top left) and `x2`, `y2` (bottom
319
+ # right), the numbers as sent: pixels of the image, or fractions of its
320
+ # width and height when every place question of the request asks for
321
+ # normalized units.
322
+ FaceBox = Struct.new(:x1, :y1, :x2, :y2) do
323
+ def self.well_formed?(raw)
324
+ raw.is_a?(Hash) && %w[x1 y1 x2 y2].all? { |k| raw[k].is_a?(Numeric) }
325
+ end
326
+
327
+ def self.from(raw)
328
+ new(raw["x1"], raw["y1"], raw["x2"], raw["y2"])
329
+ end
330
+ end
331
+
332
+ # One face a `face_detection` reading found: its `box` (a FaceBox) and its
333
+ # `confidence`, from 0 to 1.
334
+ FaceDetection = Struct.new(:box, :confidence)
335
+
336
+ # One face a `face_recognition` reading matched in a collection: its `box`
337
+ # (a FaceBox), the match `score` from 0 to 1, the `collection` searched, the
338
+ # matched item's `reference`, and its `name` (a public figure) or `label`
339
+ # (an item of your own collection that has one), each nil when absent.
340
+ # `collection_name` is an own collection's display name (or its id), or a
341
+ # public collection's id; `metadata` is an own item's metadata, a Hash of
342
+ # JSON values as sent. Each is nil when absent, never an empty Hash.
343
+ FaceMatch = Struct.new(:box, :score, :collection, :reference, :name, :label, :collection_name, :metadata)
344
+
345
+ # The faces of a face reading, checked alike for both boosters.
346
+ module FaceReadings
347
+ module_function
348
+
349
+ def counted?(raw)
350
+ raw["image"].is_a?(String) && raw["faces"].is_a?(Integer) && !raw["faces"].negative?
351
+ end
352
+
353
+ def list?(raw, key)
354
+ raw[key].is_a?(Array) && raw[key].all? { |entry| entry.is_a?(Hash) && FaceBox.well_formed?(entry["box"]) && yield(entry) }
355
+ end
356
+ end
357
+ private_constant :FaceReadings
358
+
359
+ # A reading of the `face_detection` booster: how many faces the image
360
+ # shows, and each face's box and confidence.
361
+ class FaceDetectionReading < Reading
362
+ # How many faces were found; it can be more than the faces listed, which
363
+ # are at most 64.
364
+ attr_reader :faces
365
+ # The faces found, in the order sent: each a FaceDetection.
366
+ attr_reader :detections
367
+
368
+ def self.well_formed?(raw)
369
+ FaceReadings.counted?(raw) && FaceReadings.list?(raw, "detections") { |d| number?(d["confidence"]) }
370
+ end
371
+
372
+ def initialize(raw)
373
+ super
374
+ @faces = raw["faces"]
375
+ @detections = raw["detections"].map { |d| FaceDetection.new(FaceBox.from(d["box"]), d["confidence"]) }.freeze
376
+ end
377
+ end
378
+
379
+ # A reading of the `face_recognition` booster: how many faces it searched,
380
+ # and each face that matched an item of the collection, by score.
381
+ class FaceRecognitionReading < Reading
382
+ # How many faces were searched.
383
+ attr_reader :faces
384
+ # An own collection's display name, or nil when absent (a public
385
+ # collection, or an answer from before the field).
386
+ attr_reader :collection_name
387
+ # The faces that matched, in the order sent: each a FaceMatch. Empty when
388
+ # none did.
389
+ attr_reader :matches
390
+
391
+ def self.well_formed?(raw)
392
+ FaceReadings.counted?(raw) &&
393
+ (raw["collection_name"].nil? || raw["collection_name"].is_a?(String)) &&
394
+ FaceReadings.list?(raw, "matches") do |m|
395
+ number?(m["score"]) && m["collection"].is_a?(String) && m["reference"].is_a?(String) &&
396
+ %w[name label collection_name].all? { |k| m[k].nil? || m[k].is_a?(String) } &&
397
+ (m["metadata"].nil? || m["metadata"].is_a?(Hash))
398
+ end
399
+ end
400
+
401
+ def initialize(raw)
402
+ super
403
+ @faces = raw["faces"]
404
+ @collection_name = raw["collection_name"]
405
+ @matches = raw["matches"].map do |m|
406
+ FaceMatch.new(FaceBox.from(m["box"]), m["score"], m["collection"], m["reference"], m["name"], m["label"],
407
+ m["collection_name"], m["metadata"])
408
+ end.freeze
409
+ end
410
+ end
411
+
412
+ # A reading of the `bynn_depth` booster (Depth Vision): how far away things
413
+ # are in the picture, in meters. The distances are estimates from one
414
+ # picture, for decisions and planning, never a safety stop.
415
+ class DepthReading < Reading
416
+ # Meters to the nearest obstacle straight ahead (the middle third of the
417
+ # picture, the floor or ground left out); nil when none is found.
418
+ attr_reader :ahead
419
+ # The same in 7 equal vertical strips of the picture, left to right, each
420
+ # meters or nil; a reading with another number of them is an
421
+ # UnknownReading.
422
+ attr_reader :directions
423
+ # true when a floor or ground was found and left out of `ahead` and
424
+ # `directions`; false when none was, and they include everything.
425
+ attr_reader :ground
426
+ # The grid's rows and columns.
427
+ attr_reader :grid_rows, :grid_cols
428
+ # The grid, row by row from the top left of the picture turned upright,
429
+ # each cell the distance of its part of the picture in meters.
430
+ attr_reader :grid
431
+ # The nearest, the farthest and the median distance in the picture.
432
+ attr_reader :depth_min, :depth_max, :depth_median
433
+ # The picture's size in pixels, turned upright.
434
+ attr_reader :width, :height
435
+
436
+ # A reading's directions: 7 equal vertical strips of the picture.
437
+ DIRECTIONS = 7
438
+
439
+ def self.well_formed?(raw)
440
+ raw["image"].is_a?(String) && (raw["ahead"].nil? || number?(raw["ahead"])) &&
441
+ raw["directions"].is_a?(Array) && raw["directions"].size == DIRECTIONS &&
442
+ raw["directions"].all? { |d| d.nil? || number?(d) } &&
443
+ [true, false].include?(raw["ground"]) &&
444
+ count?(raw["grid_rows"]) && count?(raw["grid_cols"]) &&
445
+ raw["grid"].is_a?(Array) && raw["grid"].all? { |row| row.is_a?(Array) && row.all? { |c| number?(c) } } &&
446
+ %w[depth_min depth_max depth_median].all? { |k| number?(raw[k]) } &&
447
+ count?(raw["width"]) && count?(raw["height"])
448
+ end
449
+
450
+ def initialize(raw)
451
+ super
452
+ @ahead = raw["ahead"]
453
+ @directions = raw["directions"]
454
+ @ground = raw["ground"]
455
+ @grid_rows = raw["grid_rows"]
456
+ @grid_cols = raw["grid_cols"]
457
+ @grid = raw["grid"]
458
+ @depth_min = raw["depth_min"]
459
+ @depth_max = raw["depth_max"]
460
+ @depth_median = raw["depth_median"]
461
+ @width = raw["width"]
462
+ @height = raw["height"]
463
+ end
464
+ end
465
+
466
+ # The opening a `bynn_segment` instance encloses, such as the hole of a
467
+ # gate, a doorway or a ring: its `polygon` (`[x, y]` points), `centroid`
468
+ # (`[x, y]`), `width` and `height`, `offset` (`[dx, dy]`, -1 to 1) and
469
+ # `area`, as fractions of the picture from its top left.
470
+ SegmentOpening = Struct.new(:polygon, :centroid, :width, :height, :offset, :area)
471
+
472
+ # How far away a `bynn_segment` instance is, with depth on: the `median`
473
+ # distance inside its outline, the `opening_median` seen through its
474
+ # opening and the `opening_rim_median` of the opening's rim, each meters or
475
+ # nil.
476
+ SegmentDepth = Struct.new(:median, :opening_median, :opening_rim_median)
477
+
478
+ # A `width` and a `height` in meters.
479
+ SegmentExtent = Struct.new(:width, :height)
480
+
481
+ # How large a `bynn_segment` instance is, with depth on: its `width` and
482
+ # `height` in meters, and its `opening`'s, a SegmentExtent or nil.
483
+ SegmentSize = Struct.new(:width, :height, :opening)
484
+
485
+ # One thing a `bynn_segment` reading found: the phrase it matched
486
+ # (`label`), its `score` from 0 to 1, `box` (`[x1, y1, x2, y2]`), `polygon`
487
+ # (its outline, `[x, y]` points), `area` and `centroid` (`[x, y]`), as
488
+ # fractions of the picture from its top left, turned upright; its
489
+ # `opening` (a SegmentOpening or nil), and with depth on its `depth` (a
490
+ # SegmentDepth) and `size_m` (a SegmentSize), each nil with depth off.
491
+ SegmentInstance = Struct.new(:label, :score, :box, :polygon, :area, :centroid, :opening, :depth, :size_m)
492
+
493
+ # The shapes of a segment reading, checked as sent.
494
+ module SegmentShapes
495
+ module_function
496
+
497
+ def numbers?(value, count = nil)
498
+ value.is_a?(Array) && value.all? { |v| v.is_a?(Numeric) } && (count.nil? || value.size == count)
499
+ end
500
+
501
+ def points?(value)
502
+ value.is_a?(Array) && value.all? { |p| numbers?(p, 2) }
503
+ end
504
+
505
+ # An object, or nil or absent.
506
+ def object_or_nil?(raw, key)
507
+ raw[key].nil? || raw[key].is_a?(Hash)
508
+ end
509
+
510
+ # A number, or nil or absent.
511
+ def nullable?(raw, key)
512
+ raw[key].nil? || raw[key].is_a?(Numeric)
513
+ end
514
+
515
+ def opening?(raw)
516
+ raw.nil? ||
517
+ (points?(raw["polygon"]) && numbers?(raw["centroid"], 2) && numbers?(raw["offset"], 2) &&
518
+ %w[width height area].all? { |k| raw[k].is_a?(Numeric) })
519
+ end
520
+
521
+ def depth?(raw)
522
+ raw.nil? || %w[median opening_median opening_rim_median].all? { |k| nullable?(raw, k) }
523
+ end
524
+
525
+ def size?(raw)
526
+ raw.nil? ||
527
+ (raw["width"].is_a?(Numeric) && raw["height"].is_a?(Numeric) && object_or_nil?(raw, "opening") &&
528
+ (raw["opening"].nil? || (raw["opening"]["width"].is_a?(Numeric) && raw["opening"]["height"].is_a?(Numeric))))
529
+ end
530
+
531
+ def instance?(raw)
532
+ raw.is_a?(Hash) && raw["label"].is_a?(String) && raw["score"].is_a?(Numeric) &&
533
+ numbers?(raw["box"], 4) && points?(raw["polygon"]) && raw["area"].is_a?(Numeric) &&
534
+ numbers?(raw["centroid"], 2) &&
535
+ %w[opening depth size_m].all? { |k| object_or_nil?(raw, k) } &&
536
+ opening?(raw["opening"]) && depth?(raw["depth"]) && size?(raw["size_m"])
537
+ end
538
+
539
+ def instance(raw)
540
+ opening = raw["opening"]
541
+ depth = raw["depth"]
542
+ size = raw["size_m"]
543
+ SegmentInstance.new(
544
+ raw["label"], raw["score"], raw["box"], raw["polygon"], raw["area"], raw["centroid"],
545
+ opening && SegmentOpening.new(opening["polygon"], opening["centroid"], opening["width"], opening["height"],
546
+ opening["offset"], opening["area"]),
547
+ depth && SegmentDepth.new(depth["median"], depth["opening_median"], depth["opening_rim_median"]),
548
+ size && SegmentSize.new(size["width"], size["height"],
549
+ size["opening"] && SegmentExtent.new(size["opening"]["width"], size["opening"]["height"]))
550
+ )
551
+ end
552
+ end
553
+ private_constant :SegmentShapes
554
+
555
+ # A reading of the `bynn_segment` booster (Segment Vision): where the things
556
+ # the request's `prompt` names are in the picture.
557
+ class SegmentReading < Reading
558
+ # The picture's size in pixels, turned upright.
559
+ attr_reader :width, :height
560
+ # The phrases searched for, cleaned as the request's `prompt` is.
561
+ attr_reader :prompts
562
+ # What was found, best first: each a SegmentInstance. Empty when nothing
563
+ # was, which is a reading.
564
+ attr_reader :instances
565
+ # Where the distances come from ("bynn_depth"); nil with depth off.
566
+ attr_reader :depth_source
567
+ # The horizontal field of view the sizes in meters assume, in degrees; nil
568
+ # when the reading has none.
569
+ attr_reader :assumed_hfov_deg
570
+
571
+ def self.well_formed?(raw)
572
+ raw["image"].is_a?(String) && count?(raw["width"]) && count?(raw["height"]) &&
573
+ raw["prompts"].is_a?(Array) && raw["prompts"].all? { |p| p.is_a?(String) } &&
574
+ raw["instances"].is_a?(Array) && raw["instances"].all? { |i| SegmentShapes.instance?(i) } &&
575
+ (raw["depth_source"].nil? || raw["depth_source"].is_a?(String)) &&
576
+ (raw["assumed_hfov_deg"].nil? || number?(raw["assumed_hfov_deg"]))
577
+ end
578
+
579
+ def initialize(raw)
580
+ super
581
+ @width = raw["width"]
582
+ @height = raw["height"]
583
+ @prompts = raw["prompts"]
584
+ @instances = raw["instances"].map { |i| SegmentShapes.instance(i) }.freeze
585
+ @depth_source = raw["depth_source"]
586
+ @assumed_hfov_deg = raw["assumed_hfov_deg"]
587
+ end
588
+ end
589
+
590
+ # One passage of a KnowledgeReading, in the order the model saw them
591
+ # (pinned first). `n` is its number in the block; `item` and `chunk` cite
592
+ # where it comes from, and `byte_start` to `byte_end` (end exclusive) are
593
+ # UTF-8 byte offsets into the item's stored text, as
594
+ # `collections.get_item(..., include_text: true)` returns it. `reference`,
595
+ # `title` and `heading` are nil for a publishable key's reading. `score` is
596
+ # the similarity (4 decimals), nil for a pin. `rendered` is false when the
597
+ # passage was left out to fit, and nil on a search's passages. `text` is
598
+ # there only with `include_text`, and always on a search. Every value is as
599
+ # sent.
600
+ class KnowledgePassage
601
+ attr_reader :n, :pinned, :item, :chunk, :byte_start, :byte_end, :reference, :title, :heading, :tokens,
602
+ :score, :lexical, :rendered, :text, :raw
603
+
604
+ def self.well_formed?(raw)
605
+ raw.is_a?(Hash) && raw["n"].is_a?(Integer) && [true, false].include?(raw["pinned"]) &&
606
+ raw["item"].is_a?(String) && raw["chunk"].is_a?(Integer) &&
607
+ raw["byte_start"].is_a?(Integer) && raw["byte_end"].is_a?(Integer) &&
608
+ %w[reference title heading text].all? { |k| Reading.optional?(raw[k], String) } &&
609
+ Reading.optional?(raw["score"], Numeric) && Reading.optional?(raw["tokens"], Integer) &&
610
+ %w[lexical rendered].all? { |k| raw[k].nil? || [true, false].include?(raw[k]) }
611
+ end
612
+
613
+ def initialize(raw)
614
+ @raw = raw
615
+ %w[n pinned item chunk byte_start byte_end reference title heading tokens score lexical rendered
616
+ text].each { |k| instance_variable_set(:"@#{k}", raw[k]) }
617
+ end
618
+
619
+ def pinned?
620
+ @pinned == true
621
+ end
622
+
623
+ # A field of the passage as the server sent it.
624
+ def [](key)
625
+ raw[key.to_s]
626
+ end
627
+
628
+ def to_h
629
+ raw
630
+ end
631
+ end
632
+
633
+ # A reading of the `knowledge` booster, and the answer of
634
+ # `collections.search`: the passages found in one knowledge base.
635
+ #
636
+ # `corpus` (`{"version"}`), `query` (`{"source", "tokens", "text"}`, the
637
+ # text only with `include_text` and on a search), `budget` (`{"mode",
638
+ # "tokens", "entry", "cause"}`), `scope` (`{"key", "value"}`) and `search`
639
+ # (`{"plan", "candidates", "fallback"}`) are Hashes as sent, nil when
640
+ # absent; `omitted` is the count of passages left out by cause, as sent.
641
+ class KnowledgeReading < Reading
642
+ # The knowledge base's id.
643
+ attr_reader :collection
644
+ # Its name, or nil (always for a publishable key's reading).
645
+ attr_reader :collection_name
646
+ attr_reader :corpus, :query, :budget, :scope, :search, :omitted
647
+ # Strings such as "scope_not_named", "over_budget", "budget_floor" and
648
+ # "pinned_filtered"; empty when there are none.
649
+ attr_reader :warnings
650
+ # Each a KnowledgePassage, in the order sent.
651
+ attr_reader :passages
652
+ # Items not ready yet.
653
+ attr_reader :pending_items
654
+ # true when a passage was dropped to fit the model's context; nil on a
655
+ # search's answer, which has none.
656
+ attr_reader :truncated
657
+
658
+ def self.well_formed?(raw)
659
+ warnings = raw["warnings"]
660
+ raw["collection"].is_a?(String) && raw["passages"].is_a?(Array) &&
661
+ raw["passages"].all? { |p| KnowledgePassage.well_formed?(p) } &&
662
+ optional?(raw["collection_name"], String) && optional?(raw["pending_items"], Integer) &&
663
+ (raw["truncated"].nil? || [true, false].include?(raw["truncated"])) &&
664
+ (warnings.nil? || (warnings.is_a?(Array) && warnings.all?(String))) &&
665
+ %w[corpus query budget scope omitted search].all? { |k| optional?(raw[k], Hash) }
666
+ end
667
+
668
+ def initialize(raw)
669
+ super
670
+ @collection = raw["collection"]
671
+ @collection_name = raw["collection_name"]
672
+ %w[corpus query budget scope search omitted pending_items truncated].each do |k|
673
+ instance_variable_set(:"@#{k}", raw[k])
674
+ end
675
+ @warnings = raw["warnings"].is_a?(Array) ? raw["warnings"] : []
676
+ @passages = raw["passages"].map { |p| KnowledgePassage.new(p) }.freeze
677
+ end
678
+ end
679
+
680
+ class Reading
681
+ KINDS = {
682
+ "age" => AgeReading,
683
+ "ai_generated" => AiGeneratedReading,
684
+ "face_detection" => FaceDetectionReading,
685
+ "face_recognition" => FaceRecognitionReading,
686
+ "ocr" => OcrReading,
687
+ "bynn_depth" => DepthReading,
688
+ "bynn_segment" => SegmentReading,
689
+ "knowledge" => KnowledgeReading
690
+ }.freeze
691
+ end
692
+ end