neighbor 0.4.0 → 0.4.2

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