neighbor 0.4.0 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09edc5a7eebbf6b14f06cb51340c5def49117a318340b4d2265321a8ce6a0bec'
4
- data.tar.gz: fc8c8319cf715612f195836c84861eb327765355a0430f2d58fb5ab57857844e
3
+ metadata.gz: dfc4af6302c7098ea40f96e9d8a19706aff46a2506cad541ff18ee07fcd11019
4
+ data.tar.gz: a79b59895ca3b99a7c048eddd20cb3602b2660425ab44463e7021ab763a26f62
5
5
  SHA512:
6
- metadata.gz: caa86d17e8a3f710988486264434767c33f8b197f9a8721d6dc762235a0bc959d5c186670f7518b9d628a771454861df1beb603a175ec804aa67cf6eb9e14361
7
- data.tar.gz: 3ac9d60c57cc3e82b617820f205282b42684517070de22af5d94878959ef00e3758fb88f821ba3d3f2369602919a41d1706314f77436c8b3e5ef95acc38e3c17
6
+ metadata.gz: 11081e687de4c79428351095477137f9140bc6c0363d09c54ece8fd5f7bbe2df802d740332f4474357f9e9e57157bd1f1f4dd3671c106d24dc7e01e2f0d84e2a
7
+ data.tar.gz: f18d787b22df7bbc00c69b1f9f6262e19c0214f6ef28814a5c05d9e8c3dae357f32596994bef60112d3f53dac0c1b51f6c99af6345c7e01b2f26cef1a7b42226
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.4.2 (2024-08-27)
2
+
3
+ - Fixed error with `nil` values
4
+
5
+ ## 0.4.1 (2024-08-26)
6
+
7
+ - Added `precision` option
8
+ - Added support for `bit` dimensions to model generator
9
+ - Fixed error with Numo arrays
10
+
1
11
  ## 0.4.0 (2024-06-25)
2
12
 
3
13
  - Added support for `halfvec` and `sparsevec` types
data/README.md CHANGED
@@ -14,7 +14,7 @@ gem "neighbor"
14
14
 
15
15
  ## Choose An Extension
16
16
 
17
- Neighbor supports two extensions: [cube](https://www.postgresql.org/docs/current/cube.html) and [vector](https://github.com/pgvector/pgvector). cube ships with Postgres, while vector supports more dimensions and approximate nearest neighbor search.
17
+ Neighbor supports two extensions: [cube](https://www.postgresql.org/docs/current/cube.html) and [pgvector](https://github.com/pgvector/pgvector). cube ships with Postgres, while pgvector supports more dimensions and approximate nearest neighbor search.
18
18
 
19
19
  For cube, run:
20
20
 
@@ -23,7 +23,7 @@ rails generate neighbor:cube
23
23
  rails db:migrate
24
24
  ```
25
25
 
26
- For vector, [install pgvector](https://github.com/pgvector/pgvector#installation) and run:
26
+ For pgvector, [install the extension](https://github.com/pgvector/pgvector#installation) and run:
27
27
 
28
28
  ```sh
29
29
  rails generate neighbor:vector
@@ -35,7 +35,7 @@ rails db:migrate
35
35
  Create a migration
36
36
 
37
37
  ```ruby
38
- class AddEmbeddingToItems < ActiveRecord::Migration[7.1]
38
+ class AddEmbeddingToItems < ActiveRecord::Migration[7.2]
39
39
  def change
40
40
  add_column :items, :embedding, :cube
41
41
  # or
@@ -70,15 +70,30 @@ Get the nearest neighbors to a vector
70
70
  Item.nearest_neighbors(:embedding, [0.9, 1.3, 1.1], distance: "euclidean").first(5)
71
71
  ```
72
72
 
73
- ## Distance
73
+ Records returned from `nearest_neighbors` will have a `neighbor_distance` attribute
74
+
75
+ ```ruby
76
+ nearest_item = item.nearest_neighbors(:embedding, distance: "euclidean").first
77
+ nearest_item.neighbor_distance
78
+ ```
79
+
80
+ See the additional docs for:
81
+
82
+ - [cube](#cube)
83
+ - [pgvector](#pgvector)
84
+
85
+ Or check out some [examples](#examples)
86
+
87
+ ## cube
88
+
89
+ ### Distance
74
90
 
75
91
  Supported values are:
76
92
 
77
93
  - `euclidean`
78
94
  - `cosine`
79
- - `taxicab` (cube only)
80
- - `chebyshev` (cube only)
81
- - `inner_product` (vector only)
95
+ - `taxicab`
96
+ - `chebyshev`
82
97
 
83
98
  For cosine distance with cube, vectors must be normalized before being stored.
84
99
 
@@ -88,18 +103,11 @@ class Item < ApplicationRecord
88
103
  end
89
104
  ```
90
105
 
91
- For inner product with cube, see [this example](examples/disco_user_recs_cube.rb).
106
+ For inner product with cube, see [this example](examples/disco/user_recs_cube.rb).
92
107
 
93
- Records returned from `nearest_neighbors` will have a `neighbor_distance` attribute
108
+ ### Dimensions
94
109
 
95
- ```ruby
96
- nearest_item = item.nearest_neighbors(:embedding, distance: "euclidean").first
97
- nearest_item.neighbor_distance
98
- ```
99
-
100
- ## Dimensions
101
-
102
- The cube data type can have up to 100 dimensions by default. See the [Postgres docs](https://www.postgresql.org/docs/current/cube.html) for how to increase this. The vector data type can have up to 16,000 dimensions, and vectors with up to 2,000 dimensions can be indexed.
110
+ The `cube` type can have up to 100 dimensions by default. See the [Postgres docs](https://www.postgresql.org/docs/current/cube.html) for how to increase this.
103
111
 
104
112
  For cube, it’s a good idea to specify the number of dimensions to ensure all records have the same number.
105
113
 
@@ -109,12 +117,29 @@ class Item < ApplicationRecord
109
117
  end
110
118
  ```
111
119
 
112
- ## Indexing
120
+ ## pgvector
121
+
122
+ ### Distance
123
+
124
+ Supported values are:
125
+
126
+ - `euclidean`
127
+ - `inner_product`
128
+ - `cosine`
129
+ - `taxicab`
130
+ - `hamming`
131
+ - `jaccard`
113
132
 
114
- For vector, add an approximate index to speed up queries. Create a migration with:
133
+ ### Dimensions
134
+
135
+ The `vector` type can have up to 16,000 dimensions, and vectors with up to 2,000 dimensions can be indexed.
136
+
137
+ ### Indexing
138
+
139
+ Add an approximate index to speed up queries. Create a migration with:
115
140
 
116
141
  ```ruby
117
- class AddIndexToItemsEmbedding < ActiveRecord::Migration[7.1]
142
+ class AddIndexToItemsEmbedding < ActiveRecord::Migration[7.2]
118
143
  def change
119
144
  add_index :items, :embedding, using: :hnsw, opclass: :vector_l2_ops
120
145
  # or
@@ -137,9 +162,91 @@ Or the number of probes with IVFFlat
137
162
  Item.connection.execute("SET ivfflat.probes = 3")
138
163
  ```
139
164
 
165
+ ### Half-Precision Vectors
166
+
167
+ Use the `halfvec` type to store half-precision vectors
168
+
169
+ ```ruby
170
+ class AddEmbeddingToItems < ActiveRecord::Migration[7.2]
171
+ def change
172
+ add_column :items, :embedding, :halfvec, limit: 3 # dimensions
173
+ end
174
+ end
175
+ ```
176
+
177
+ ### Half-Precision Indexing
178
+
179
+ Index vectors at half precision for smaller indexes
180
+
181
+ ```ruby
182
+ class AddIndexToItemsEmbedding < ActiveRecord::Migration[7.2]
183
+ def change
184
+ add_index :items, "(embedding::halfvec(3)) vector_l2_ops", using: :hnsw
185
+ end
186
+ end
187
+ ```
188
+
189
+ Get the nearest neighbors
190
+
191
+ ```ruby
192
+ Item.nearest_neighbors(:embedding, [0.9, 1.3, 1.1], distance: "euclidean", precision: "half").first(5)
193
+ ```
194
+
195
+ ### Binary Vectors
196
+
197
+ Use the `bit` type to store binary vectors
198
+
199
+ ```ruby
200
+ class AddEmbeddingToItems < ActiveRecord::Migration[7.2]
201
+ def change
202
+ add_column :items, :embedding, :bit, limit: 3 # dimensions
203
+ end
204
+ end
205
+ ```
206
+
207
+ Get the nearest neighbors by Hamming distance
208
+
209
+ ```ruby
210
+ Item.nearest_neighbors(:embedding, "101", distance: "hamming").first(5)
211
+ ```
212
+
213
+ ### Binary Quantization
214
+
215
+ Use expression indexing for binary quantization
216
+
217
+ ```ruby
218
+ class AddIndexToItemsEmbedding < ActiveRecord::Migration[7.2]
219
+ def change
220
+ add_index :items, "(binary_quantize(embedding)::bit(3)) bit_hamming_ops", using: :hnsw
221
+ end
222
+ end
223
+ ```
224
+
225
+ ### Sparse Vectors
226
+
227
+ Use the `sparsevec` type to store sparse vectors
228
+
229
+ ```ruby
230
+ class AddEmbeddingToItems < ActiveRecord::Migration[7.2]
231
+ def change
232
+ add_column :items, :embedding, :sparsevec, limit: 3 # dimensions
233
+ end
234
+ end
235
+ ```
236
+
237
+ Get the nearest neighbors
238
+
239
+ ```ruby
240
+ embedding = Neighbor::SparseVector.new({0 => 0.9, 1 => 1.3, 2 => 1.1}, 3)
241
+ Item.nearest_neighbors(:embedding, embedding, distance: "euclidean").first(5)
242
+ ```
243
+
140
244
  ## Examples
141
245
 
142
246
  - [OpenAI Embeddings](#openai-embeddings)
247
+ - [Cohere Embeddings](#cohere-embeddings)
248
+ - [Sentence Embeddings](#sentence-embeddings)
249
+ - [Sparse Embeddings](#sparse-embeddings)
143
250
  - [Disco Recommendations](#disco-recommendations)
144
251
 
145
252
  ### OpenAI Embeddings
@@ -170,10 +277,10 @@ def fetch_embeddings(input)
170
277
  }
171
278
  data = {
172
279
  input: input,
173
- model: "text-embedding-ada-002"
280
+ model: "text-embedding-3-small"
174
281
  }
175
282
 
176
- response = Net::HTTP.post(URI(url), data.to_json, headers)
283
+ response = Net::HTTP.post(URI(url), data.to_json, headers).tap(&:value)
177
284
  JSON.parse(response.body)["data"].map { |v| v["embedding"] }
178
285
  end
179
286
  ```
@@ -199,14 +306,221 @@ end
199
306
  Document.insert_all!(documents)
200
307
  ```
201
308
 
202
- And get similar articles
309
+ And get similar documents
310
+
311
+ ```ruby
312
+ document = Document.first
313
+ document.nearest_neighbors(:embedding, distance: "cosine").first(5).map(&:content)
314
+ ```
315
+
316
+ See the [complete code](examples/openai/example.rb)
317
+
318
+ ### Cohere Embeddings
319
+
320
+ Generate a model
321
+
322
+ ```sh
323
+ rails generate model Document content:text embedding:bit{1024}
324
+ rails db:migrate
325
+ ```
326
+
327
+ And add `has_neighbors`
328
+
329
+ ```ruby
330
+ class Document < ApplicationRecord
331
+ has_neighbors :embedding
332
+ end
333
+ ```
334
+
335
+ Create a method to call the [embed API](https://docs.cohere.com/reference/embed)
336
+
337
+ ```ruby
338
+ def fetch_embeddings(input, input_type)
339
+ url = "https://api.cohere.com/v1/embed"
340
+ headers = {
341
+ "Authorization" => "Bearer #{ENV.fetch("CO_API_KEY")}",
342
+ "Content-Type" => "application/json"
343
+ }
344
+ data = {
345
+ texts: input,
346
+ model: "embed-english-v3.0",
347
+ input_type: input_type,
348
+ embedding_types: ["ubinary"]
349
+ }
350
+
351
+ response = Net::HTTP.post(URI(url), data.to_json, headers).tap(&:value)
352
+ JSON.parse(response.body)["embeddings"]["ubinary"].map { |e| e.map { |v| v.chr.unpack1("B*") }.join }
353
+ end
354
+ ```
355
+
356
+ Pass your input
357
+
358
+ ```ruby
359
+ input = [
360
+ "The dog is barking",
361
+ "The cat is purring",
362
+ "The bear is growling"
363
+ ]
364
+ embeddings = fetch_embeddings(input, "search_document")
365
+ ```
366
+
367
+ Store the embeddings
368
+
369
+ ```ruby
370
+ documents = []
371
+ input.zip(embeddings) do |content, embedding|
372
+ documents << {content: content, embedding: embedding}
373
+ end
374
+ Document.insert_all!(documents)
375
+ ```
376
+
377
+ Embed the search query
378
+
379
+ ```ruby
380
+ query = "forest"
381
+ query_embedding = fetch_embeddings([query], "search_query")[0]
382
+ ```
383
+
384
+ And search the documents
385
+
386
+ ```ruby
387
+ Document.nearest_neighbors(:embedding, query_embedding, distance: "hamming").first(5).map(&:content)
388
+ ```
389
+
390
+ See the [complete code](examples/cohere/example.rb)
391
+
392
+ ### Sentence Embeddings
393
+
394
+ You can generate embeddings locally with [Informers](https://github.com/ankane/informers).
395
+
396
+ Generate a model
397
+
398
+ ```sh
399
+ rails generate model Document content:text embedding:vector{384}
400
+ rails db:migrate
401
+ ```
402
+
403
+ And add `has_neighbors`
404
+
405
+ ```ruby
406
+ class Document < ApplicationRecord
407
+ has_neighbors :embedding
408
+ end
409
+ ```
410
+
411
+ Load a [model](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2)
412
+
413
+ ```ruby
414
+ model = Informers::Model.new("sentence-transformers/all-MiniLM-L6-v2")
415
+ ```
416
+
417
+ Pass your input
418
+
419
+ ```ruby
420
+ input = [
421
+ "The dog is barking",
422
+ "The cat is purring",
423
+ "The bear is growling"
424
+ ]
425
+ embeddings = model.embed(input)
426
+ ```
427
+
428
+ Store the embeddings
429
+
430
+ ```ruby
431
+ documents = []
432
+ input.zip(embeddings) do |content, embedding|
433
+ documents << {content: content, embedding: embedding}
434
+ end
435
+ Document.insert_all!(documents)
436
+ ```
437
+
438
+ And get similar documents
203
439
 
204
440
  ```ruby
205
441
  document = Document.first
206
442
  document.nearest_neighbors(:embedding, distance: "cosine").first(5).map(&:content)
207
443
  ```
208
444
 
209
- See the [complete code](examples/openai_embeddings.rb)
445
+ See the [complete code](examples/informers/example.rb)
446
+
447
+ ### Sparse Embeddings
448
+
449
+ You can generate sparse embeddings locally with [Transformers.rb](https://github.com/ankane/transformers-ruby).
450
+
451
+ Generate a model
452
+
453
+ ```sh
454
+ rails generate model Document content:text embedding:sparsevec{30522}
455
+ rails db:migrate
456
+ ```
457
+
458
+ And add `has_neighbors`
459
+
460
+ ```ruby
461
+ class Document < ApplicationRecord
462
+ has_neighbors :embedding
463
+ end
464
+ ```
465
+
466
+ Load a [model](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-v1) to generate embeddings
467
+
468
+ ```ruby
469
+ class EmbeddingModel
470
+ def initialize(model_id)
471
+ @model = Transformers::AutoModelForMaskedLM.from_pretrained(model_id)
472
+ @tokenizer = Transformers::AutoTokenizer.from_pretrained(model_id)
473
+ @special_token_ids = @tokenizer.special_tokens_map.map { |_, token| @tokenizer.vocab[token] }
474
+ end
475
+
476
+ def embed(input)
477
+ feature = @tokenizer.(input, padding: true, truncation: true, return_tensors: "pt", return_token_type_ids: false)
478
+ output = @model.(**feature)[0]
479
+ values = Torch.max(output * feature[:attention_mask].unsqueeze(-1), dim: 1)[0]
480
+ values = Torch.log(1 + Torch.relu(values))
481
+ values[0.., @special_token_ids] = 0
482
+ values.to_a
483
+ end
484
+ end
485
+
486
+ model = EmbeddingModel.new("opensearch-project/opensearch-neural-sparse-encoding-v1")
487
+ ```
488
+
489
+ Pass your input
490
+
491
+ ```ruby
492
+ input = [
493
+ "The dog is barking",
494
+ "The cat is purring",
495
+ "The bear is growling"
496
+ ]
497
+ embeddings = model.embed(input)
498
+ ```
499
+
500
+ Store the embeddings
501
+
502
+ ```ruby
503
+ documents = []
504
+ input.zip(embeddings) do |content, embedding|
505
+ documents << {content: content, embedding: Neighbor::SparseVector.new(embedding)}
506
+ end
507
+ Document.insert_all!(documents)
508
+ ```
509
+
510
+ Embed the search query
511
+
512
+ ```ruby
513
+ query = "forest"
514
+ query_embedding = model.embed([query])[0]
515
+ ```
516
+
517
+ And search the documents
518
+
519
+ ```ruby
520
+ Document.nearest_neighbors(:embedding, Neighbor::SparseVector.new(query_embedding), distance: "inner_product").first(5).map(&:content)
521
+ ```
522
+
523
+ See the [complete code](examples/sparse/example.rb)
210
524
 
211
525
  ### Disco Recommendations
212
526
 
@@ -252,19 +566,7 @@ movie = Movie.find_by(name: "Star Wars (1977)")
252
566
  movie.nearest_neighbors(:factors, distance: "cosine").first(5).map(&:name)
253
567
  ```
254
568
 
255
- See the complete code for [cube](examples/disco_item_recs_cube.rb) and [vector](examples/disco_item_recs_vector.rb)
256
-
257
- ## Upgrading
258
-
259
- ### 0.2.0
260
-
261
- The `distance` option has been moved from `has_neighbors` to `nearest_neighbors`, and there is no longer a default. If you use cosine distance, set:
262
-
263
- ```ruby
264
- class Item < ApplicationRecord
265
- has_neighbors normalize: true
266
- end
267
- ```
569
+ See the complete code for [cube](examples/disco/item_recs_cube.rb) and [pgvector](examples/disco/item_recs_vector.rb)
268
570
 
269
571
  ## History
270
572
 
@@ -49,7 +49,7 @@ module Neighbor
49
49
  # TODO move to normalizes when Active Record < 7.1 no longer supported
50
50
  before_save do
51
51
  self.class.neighbor_attributes.each do |k, v|
52
- next unless v[:normalize]
52
+ next unless v[:normalize] && attribute_changed?(k)
53
53
  value = read_attribute(k)
54
54
  next if value.nil?
55
55
  self[k] = Neighbor::Utils.normalize(value, column_info: self.class.columns_hash[k.to_s])
@@ -61,6 +61,7 @@ module Neighbor
61
61
  scope :nearest_neighbors, ->(attribute_name, vector, options = nil) {
62
62
  raise ArgumentError, "missing keyword: :distance" unless options.is_a?(Hash) && options.key?(:distance)
63
63
  distance = options.delete(:distance)
64
+ precision = options.delete(:precision)
64
65
  raise ArgumentError, "unknown keywords: #{options.keys.map(&:inspect).join(", ")}" if options.any?
65
66
 
66
67
  attribute_name = attribute_name.to_sym
@@ -126,6 +127,18 @@ module Neighbor
126
127
  vector = Neighbor::Utils.normalize(vector, column_info: column_info) if normalize
127
128
 
128
129
  query = connection.quote(column_attribute.serialize(vector))
130
+
131
+ if !precision.nil?
132
+ case precision.to_s
133
+ when "half"
134
+ cast_dimensions = dimensions || column_info&.limit
135
+ raise ArgumentError, "Unknown dimensions" unless cast_dimensions
136
+ quoted_attribute += "::halfvec(#{connection.quote(cast_dimensions.to_i)})"
137
+ else
138
+ raise ArgumentError, "Invalid precision"
139
+ end
140
+ end
141
+
129
142
  order = "#{quoted_attribute} #{operator} #{query}"
130
143
  if operator == "#"
131
144
  order = "bit_count(#{order})"
@@ -10,7 +10,7 @@ module Neighbor
10
10
 
11
11
  module GeneratedAttribute
12
12
  def parse_type_and_options(type, *, **)
13
- if type =~ /\A(vector|halfvec|sparsevec)\{(\d+)\}\z/
13
+ if type =~ /\A(vector|halfvec|bit|sparsevec)\{(\d+)\}\z/
14
14
  return $1, limit: $2.to_i
15
15
  end
16
16
  super
@@ -6,7 +6,8 @@ module Neighbor
6
6
  end
7
7
 
8
8
  def serialize(value)
9
- if value.is_a?(Array)
9
+ if Utils.array?(value)
10
+ value = value.to_a
10
11
  if value.first.is_a?(Array)
11
12
  value = value.map { |v| serialize_point(v) }.join(", ")
12
13
  else
@@ -19,8 +20,8 @@ module Neighbor
19
20
  private
20
21
 
21
22
  def cast_value(value)
22
- if value.is_a?(Array)
23
- value
23
+ if Utils.array?(value)
24
+ value.to_a
24
25
  elsif value.is_a?(Numeric)
25
26
  [value]
26
27
  elsif value.is_a?(String)
@@ -6,8 +6,8 @@ module Neighbor
6
6
  end
7
7
 
8
8
  def serialize(value)
9
- if value.is_a?(Array)
10
- value = "[#{value.map(&:to_f).join(",")}]"
9
+ if Utils.array?(value)
10
+ value = "[#{value.to_a.map(&:to_f).join(",")}]"
11
11
  end
12
12
  super(value)
13
13
  end
@@ -17,8 +17,8 @@ module Neighbor
17
17
  def cast_value(value)
18
18
  if value.is_a?(String)
19
19
  value[1..-1].split(",").map(&:to_f)
20
- elsif value.is_a?(Array)
21
- value
20
+ elsif Utils.array?(value)
21
+ value.to_a
22
22
  else
23
23
  raise "can't cast #{value.class.name} to halfvec"
24
24
  end
@@ -19,8 +19,8 @@ module Neighbor
19
19
  value
20
20
  elsif value.is_a?(String)
21
21
  SparseVector.from_text(value)
22
- elsif value.is_a?(Array)
23
- value = SparseVector.new(value)
22
+ elsif Utils.array?(value)
23
+ value = SparseVector.new(value.to_a)
24
24
  else
25
25
  raise "can't cast #{value.class.name} to sparsevec"
26
26
  end
@@ -6,8 +6,8 @@ module Neighbor
6
6
  end
7
7
 
8
8
  def serialize(value)
9
- if value.is_a?(Array)
10
- value = "[#{value.map(&:to_f).join(",")}]"
9
+ if Utils.array?(value)
10
+ value = "[#{value.to_a.map(&:to_f).join(",")}]"
11
11
  end
12
12
  super(value)
13
13
  end
@@ -17,8 +17,8 @@ module Neighbor
17
17
  def cast_value(value)
18
18
  if value.is_a?(String)
19
19
  value[1..-1].split(",").map(&:to_f)
20
- elsif value.is_a?(Array)
21
- value
20
+ elsif Utils.array?(value)
21
+ value.to_a
22
22
  else
23
23
  raise "can't cast #{value.class.name} to vector"
24
24
  end
@@ -38,5 +38,9 @@ module Neighbor
38
38
  # could also throw error
39
39
  norm > 0 ? value.map { |v| v / norm } : value
40
40
  end
41
+
42
+ def self.array?(value)
43
+ !value.nil? && value.respond_to?(:to_a)
44
+ end
41
45
  end
42
46
  end
@@ -1,3 +1,3 @@
1
1
  module Neighbor
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: neighbor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-06-26 00:00:00.000000000 Z
11
+ date: 2024-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord