ruby_llm-turbovec 0.1.0

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,399 @@
1
+ use magnus::{exception, function, method, prelude::*, Error, Ruby};
2
+ use rb_sys as _;
3
+ use std::path::Path;
4
+ use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
5
+ use turbovec::{
6
+ AddError, ConstructError, IdMapIndex as NativeIdMapIndex,
7
+ SearchResults as NativeSearchResults, TurboQuantIndex as NativeTurboQuantIndex,
8
+ };
9
+
10
+ fn construct_error(err: ConstructError) -> Error {
11
+ Error::new(exception::arg_error(), err.to_string())
12
+ }
13
+
14
+ fn add_error(err: AddError) -> Error {
15
+ let class = match err {
16
+ AddError::IdAlreadyPresent(_) => exception::key_error(),
17
+ _ => exception::arg_error(),
18
+ };
19
+ Error::new(class, err.to_string())
20
+ }
21
+
22
+ fn io_error(err: std::io::Error) -> Error {
23
+ Error::new(exception::runtime_error(), err.to_string())
24
+ }
25
+
26
+ fn lock_read<'a, T>(lock: &'a RwLock<T>) -> Result<RwLockReadGuard<'a, T>, Error> {
27
+ lock.read()
28
+ .map_err(|_| Error::new(exception::runtime_error(), "index lock poisoned"))
29
+ }
30
+
31
+ fn lock_write<'a, T>(lock: &'a RwLock<T>) -> Result<RwLockWriteGuard<'a, T>, Error> {
32
+ lock.write()
33
+ .map_err(|_| Error::new(exception::runtime_error(), "index lock poisoned"))
34
+ }
35
+
36
+ fn validate_queries(queries: &[f32], dim: Option<usize>, kind: &str) -> Result<(), Error> {
37
+ if let Some(dim) = dim {
38
+ if dim != 0 && queries.len() % dim != 0 {
39
+ return Err(Error::new(
40
+ exception::arg_error(),
41
+ format!(
42
+ "{kind} buffer length {} is not a multiple of dim {}",
43
+ queries.len(),
44
+ dim
45
+ ),
46
+ ));
47
+ }
48
+ }
49
+
50
+ Ok(())
51
+ }
52
+
53
+ fn query_range(qi: usize, nq: usize, k: usize) -> Result<std::ops::Range<usize>, Error> {
54
+ if qi >= nq {
55
+ return Err(Error::new(
56
+ exception::arg_error(),
57
+ format!("query index {qi} out of bounds for {nq} queries"),
58
+ ));
59
+ }
60
+
61
+ let start = qi * k;
62
+ Ok(start..start + k)
63
+ }
64
+
65
+ #[magnus::wrap(class = "RubyLLM::Turbovec::SearchResults", free_immediately)]
66
+ struct RubySearchResults {
67
+ scores: Vec<f32>,
68
+ indices: Vec<i64>,
69
+ nq: usize,
70
+ k: usize,
71
+ }
72
+
73
+ impl From<NativeSearchResults> for RubySearchResults {
74
+ fn from(results: NativeSearchResults) -> Self {
75
+ Self {
76
+ scores: results.scores,
77
+ indices: results.indices,
78
+ nq: results.nq,
79
+ k: results.k,
80
+ }
81
+ }
82
+ }
83
+
84
+ impl RubySearchResults {
85
+ fn scores(&self) -> Vec<f32> {
86
+ self.scores.clone()
87
+ }
88
+
89
+ fn indices(&self) -> Vec<i64> {
90
+ self.indices.clone()
91
+ }
92
+
93
+ fn nq(&self) -> usize {
94
+ self.nq
95
+ }
96
+
97
+ fn k(&self) -> usize {
98
+ self.k
99
+ }
100
+
101
+ fn scores_for_query(&self, qi: usize) -> Result<Vec<f32>, Error> {
102
+ let range = query_range(qi, self.nq, self.k)?;
103
+ Ok(self.scores[range].to_vec())
104
+ }
105
+
106
+ fn indices_for_query(&self, qi: usize) -> Result<Vec<i64>, Error> {
107
+ let range = query_range(qi, self.nq, self.k)?;
108
+ Ok(self.indices[range].to_vec())
109
+ }
110
+ }
111
+
112
+ #[magnus::wrap(class = "RubyLLM::Turbovec::TurboQuantIndex", free_immediately)]
113
+ struct RubyTurboQuantIndex {
114
+ inner: RwLock<NativeTurboQuantIndex>,
115
+ }
116
+
117
+ impl RubyTurboQuantIndex {
118
+ fn new(dim: usize, bit_width: usize) -> Result<Self, Error> {
119
+ NativeTurboQuantIndex::new(dim, bit_width)
120
+ .map(|inner| Self {
121
+ inner: RwLock::new(inner),
122
+ })
123
+ .map_err(construct_error)
124
+ }
125
+
126
+ fn new_lazy(bit_width: usize) -> Result<Self, Error> {
127
+ NativeTurboQuantIndex::new_lazy(bit_width)
128
+ .map(|inner| Self {
129
+ inner: RwLock::new(inner),
130
+ })
131
+ .map_err(construct_error)
132
+ }
133
+
134
+ fn load(path: String) -> Result<Self, Error> {
135
+ NativeTurboQuantIndex::load(Path::new(&path))
136
+ .map(|inner| Self {
137
+ inner: RwLock::new(inner),
138
+ })
139
+ .map_err(io_error)
140
+ }
141
+
142
+ fn add(&self, vectors: Vec<f32>) -> Result<(), Error> {
143
+ let mut inner = lock_write(&self.inner)?;
144
+ let dim = inner.dim_opt().ok_or_else(|| {
145
+ Error::new(
146
+ exception::arg_error(),
147
+ "index dimension is not set; use add_with_dim on lazy indexes",
148
+ )
149
+ })?;
150
+
151
+ inner.add_2d(&vectors, dim).map_err(add_error)
152
+ }
153
+
154
+ fn add_with_dim(&self, vectors: Vec<f32>, dim: usize) -> Result<(), Error> {
155
+ lock_write(&self.inner)?.add_2d(&vectors, dim).map_err(add_error)
156
+ }
157
+
158
+ fn search(&self, queries: Vec<f32>, k: usize) -> Result<RubySearchResults, Error> {
159
+ let inner = lock_read(&self.inner)?;
160
+ validate_queries(&queries, inner.dim_opt(), "query")?;
161
+ Ok(inner.search(&queries, k).into())
162
+ }
163
+
164
+ fn search_with_mask(
165
+ &self,
166
+ queries: Vec<f32>,
167
+ k: usize,
168
+ mask: Option<Vec<bool>>,
169
+ ) -> Result<RubySearchResults, Error> {
170
+ let inner = lock_read(&self.inner)?;
171
+ validate_queries(&queries, inner.dim_opt(), "query")?;
172
+
173
+ if let Some(ref mask) = mask {
174
+ if mask.len() != inner.len() {
175
+ return Err(Error::new(
176
+ exception::arg_error(),
177
+ format!(
178
+ "mask length {} does not match index size {}",
179
+ mask.len(),
180
+ inner.len()
181
+ ),
182
+ ));
183
+ }
184
+ }
185
+
186
+ Ok(inner.search_with_mask(&queries, k, mask.as_deref()).into())
187
+ }
188
+
189
+ fn prepare(&self) -> Result<(), Error> {
190
+ lock_read(&self.inner)?.prepare();
191
+ Ok(())
192
+ }
193
+
194
+ fn write(&self, path: String) -> Result<(), Error> {
195
+ lock_read(&self.inner)?
196
+ .write(Path::new(&path))
197
+ .map_err(io_error)
198
+ }
199
+
200
+ fn len(&self) -> Result<usize, Error> {
201
+ Ok(lock_read(&self.inner)?.len())
202
+ }
203
+
204
+ fn is_empty(&self) -> Result<bool, Error> {
205
+ Ok(lock_read(&self.inner)?.is_empty())
206
+ }
207
+
208
+ fn dim(&self) -> Result<usize, Error> {
209
+ Ok(lock_read(&self.inner)?.dim())
210
+ }
211
+
212
+ fn dim_opt(&self) -> Result<Option<usize>, Error> {
213
+ Ok(lock_read(&self.inner)?.dim_opt())
214
+ }
215
+
216
+ fn bit_width(&self) -> Result<usize, Error> {
217
+ Ok(lock_read(&self.inner)?.bit_width())
218
+ }
219
+
220
+ fn swap_remove(&self, idx: usize) -> Result<usize, Error> {
221
+ Ok(lock_write(&self.inner)?.swap_remove(idx))
222
+ }
223
+ }
224
+
225
+ #[magnus::wrap(class = "RubyLLM::Turbovec::IdMapIndex", free_immediately)]
226
+ struct RubyIdMapIndex {
227
+ inner: RwLock<NativeIdMapIndex>,
228
+ }
229
+
230
+ impl RubyIdMapIndex {
231
+ fn new(dim: usize, bit_width: usize) -> Result<Self, Error> {
232
+ NativeIdMapIndex::new(dim, bit_width)
233
+ .map(|inner| Self {
234
+ inner: RwLock::new(inner),
235
+ })
236
+ .map_err(construct_error)
237
+ }
238
+
239
+ fn new_lazy(bit_width: usize) -> Result<Self, Error> {
240
+ NativeIdMapIndex::new_lazy(bit_width)
241
+ .map(|inner| Self {
242
+ inner: RwLock::new(inner),
243
+ })
244
+ .map_err(construct_error)
245
+ }
246
+
247
+ fn load(path: String) -> Result<Self, Error> {
248
+ NativeIdMapIndex::load(Path::new(&path))
249
+ .map(|inner| Self {
250
+ inner: RwLock::new(inner),
251
+ })
252
+ .map_err(io_error)
253
+ }
254
+
255
+ fn add_with_ids(&self, vectors: Vec<f32>, ids: Vec<u64>) -> Result<(), Error> {
256
+ let mut inner = lock_write(&self.inner)?;
257
+ if inner.dim_opt().is_none() {
258
+ return Err(Error::new(
259
+ exception::arg_error(),
260
+ "index dimension is not set; use add_with_ids_2d on lazy indexes",
261
+ ));
262
+ }
263
+
264
+ inner.add_with_ids(&vectors, &ids).map_err(add_error)
265
+ }
266
+
267
+ fn add_with_ids_2d(&self, vectors: Vec<f32>, dim: usize, ids: Vec<u64>) -> Result<(), Error> {
268
+ lock_write(&self.inner)?
269
+ .add_with_ids_2d(&vectors, dim, &ids)
270
+ .map_err(add_error)
271
+ }
272
+
273
+ fn remove(&self, id: u64) -> Result<bool, Error> {
274
+ Ok(lock_write(&self.inner)?.remove(id))
275
+ }
276
+
277
+ fn search(&self, queries: Vec<f32>, k: usize) -> Result<(Vec<f32>, Vec<u64>), Error> {
278
+ let inner = lock_read(&self.inner)?;
279
+ validate_queries(&queries, inner.dim_opt(), "query")?;
280
+ Ok(inner.search(&queries, k))
281
+ }
282
+
283
+ fn search_with_allowlist(
284
+ &self,
285
+ queries: Vec<f32>,
286
+ k: usize,
287
+ allowlist: Option<Vec<u64>>,
288
+ ) -> Result<(Vec<f32>, Vec<u64>), Error> {
289
+ let inner = lock_read(&self.inner)?;
290
+ validate_queries(&queries, inner.dim_opt(), "query")?;
291
+
292
+ if let Some(ref ids) = allowlist {
293
+ if ids.is_empty() {
294
+ return Err(Error::new(exception::arg_error(), "allowlist is empty"));
295
+ }
296
+
297
+ for id in ids {
298
+ if !inner.contains(*id) {
299
+ return Err(Error::new(
300
+ exception::key_error(),
301
+ format!("id {id} in allowlist is not present in index"),
302
+ ));
303
+ }
304
+ }
305
+ }
306
+
307
+ Ok(inner.search_with_allowlist(&queries, k, allowlist.as_deref()))
308
+ }
309
+
310
+ fn contains(&self, id: u64) -> Result<bool, Error> {
311
+ Ok(lock_read(&self.inner)?.contains(id))
312
+ }
313
+
314
+ fn prepare(&self) -> Result<(), Error> {
315
+ lock_read(&self.inner)?.prepare();
316
+ Ok(())
317
+ }
318
+
319
+ fn write(&self, path: String) -> Result<(), Error> {
320
+ lock_read(&self.inner)?
321
+ .write(Path::new(&path))
322
+ .map_err(io_error)
323
+ }
324
+
325
+ fn len(&self) -> Result<usize, Error> {
326
+ Ok(lock_read(&self.inner)?.len())
327
+ }
328
+
329
+ fn is_empty(&self) -> Result<bool, Error> {
330
+ Ok(lock_read(&self.inner)?.is_empty())
331
+ }
332
+
333
+ fn dim(&self) -> Result<usize, Error> {
334
+ Ok(lock_read(&self.inner)?.dim())
335
+ }
336
+
337
+ fn dim_opt(&self) -> Result<Option<usize>, Error> {
338
+ Ok(lock_read(&self.inner)?.dim_opt())
339
+ }
340
+
341
+ fn bit_width(&self) -> Result<usize, Error> {
342
+ Ok(lock_read(&self.inner)?.bit_width())
343
+ }
344
+ }
345
+
346
+ #[magnus::init]
347
+ fn init(ruby: &Ruby) -> Result<(), Error> {
348
+ let ruby_llm = ruby.define_module("RubyLLM")?;
349
+ let turbovec = ruby_llm.define_module("Turbovec")?;
350
+
351
+ let index_class = turbovec.define_class("TurboQuantIndex", ruby.class_object())?;
352
+ index_class.undef_default_alloc_func();
353
+ index_class.define_singleton_method("new", function!(RubyTurboQuantIndex::new, 2))?;
354
+ index_class.define_singleton_method("new_lazy", function!(RubyTurboQuantIndex::new_lazy, 1))?;
355
+ index_class.define_singleton_method("load", function!(RubyTurboQuantIndex::load, 1))?;
356
+ index_class.define_method("add", method!(RubyTurboQuantIndex::add, 1))?;
357
+ index_class.define_method("add_with_dim", method!(RubyTurboQuantIndex::add_with_dim, 2))?;
358
+ index_class.define_method("search", method!(RubyTurboQuantIndex::search, 2))?;
359
+ index_class.define_method("search_with_mask", method!(RubyTurboQuantIndex::search_with_mask, 3))?;
360
+ index_class.define_method("prepare", method!(RubyTurboQuantIndex::prepare, 0))?;
361
+ index_class.define_method("write", method!(RubyTurboQuantIndex::write, 1))?;
362
+ index_class.define_method("len", method!(RubyTurboQuantIndex::len, 0))?;
363
+ index_class.define_method("empty?", method!(RubyTurboQuantIndex::is_empty, 0))?;
364
+ index_class.define_method("dim", method!(RubyTurboQuantIndex::dim, 0))?;
365
+ index_class.define_method("dim_opt", method!(RubyTurboQuantIndex::dim_opt, 0))?;
366
+ index_class.define_method("bit_width", method!(RubyTurboQuantIndex::bit_width, 0))?;
367
+ index_class.define_method("swap_remove", method!(RubyTurboQuantIndex::swap_remove, 1))?;
368
+
369
+ let results_class = turbovec.define_class("SearchResults", ruby.class_object())?;
370
+ results_class.undef_default_alloc_func();
371
+ results_class.define_method("scores", method!(RubySearchResults::scores, 0))?;
372
+ results_class.define_method("indices", method!(RubySearchResults::indices, 0))?;
373
+ results_class.define_method("nq", method!(RubySearchResults::nq, 0))?;
374
+ results_class.define_method("k", method!(RubySearchResults::k, 0))?;
375
+ results_class.define_method("scores_for_query", method!(RubySearchResults::scores_for_query, 1))?;
376
+ results_class.define_method("indices_for_query", method!(RubySearchResults::indices_for_query, 1))?;
377
+
378
+ let id_map_class = turbovec.define_class("IdMapIndex", ruby.class_object())?;
379
+ id_map_class.undef_default_alloc_func();
380
+ id_map_class.define_singleton_method("new", function!(RubyIdMapIndex::new, 2))?;
381
+ id_map_class.define_singleton_method("new_lazy", function!(RubyIdMapIndex::new_lazy, 1))?;
382
+ id_map_class.define_singleton_method("load", function!(RubyIdMapIndex::load, 1))?;
383
+ id_map_class.define_method("add_with_ids", method!(RubyIdMapIndex::add_with_ids, 2))?;
384
+ id_map_class.define_method("add_with_ids_2d", method!(RubyIdMapIndex::add_with_ids_2d, 3))?;
385
+ id_map_class.define_method("remove", method!(RubyIdMapIndex::remove, 1))?;
386
+ id_map_class.define_method("search", method!(RubyIdMapIndex::search, 2))?;
387
+ id_map_class.define_method("search_with_allowlist", method!(RubyIdMapIndex::search_with_allowlist, 3))?;
388
+ id_map_class.define_method("contains?", method!(RubyIdMapIndex::contains, 1))?;
389
+ id_map_class.define_method("prepare", method!(RubyIdMapIndex::prepare, 0))?;
390
+ id_map_class.define_method("write", method!(RubyIdMapIndex::write, 1))?;
391
+ id_map_class.define_method("len", method!(RubyIdMapIndex::len, 0))?;
392
+ id_map_class.define_method("empty?", method!(RubyIdMapIndex::is_empty, 0))?;
393
+ id_map_class.define_method("dim", method!(RubyIdMapIndex::dim, 0))?;
394
+ id_map_class.define_method("dim_opt", method!(RubyIdMapIndex::dim_opt, 0))?;
395
+ id_map_class.define_method("bit_width", method!(RubyIdMapIndex::bit_width, 0))?;
396
+
397
+ Ok(())
398
+ }
399
+
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Turbovec
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "turbovec/version"
4
+
5
+ begin
6
+ require "ruby_llm/turbovec/ruby_llm_turbovec"
7
+ rescue LoadError
8
+ require_relative "../../ext/ruby_llm/turbovec/ruby_llm_turbovec"
9
+ end
10
+
11
+ module RubyLLM
12
+ module Turbovec
13
+ class Error < StandardError; end
14
+ end
15
+ end
@@ -0,0 +1,53 @@
1
+ module RubyLLM
2
+ module Turbovec
3
+ VERSION: ::String
4
+
5
+ class TurboQuantIndex
6
+ def self.new: (::Integer, ::Integer) -> TurboQuantIndex
7
+ def self.new_lazy: (::Integer) -> TurboQuantIndex
8
+ def self.load: (::String) -> TurboQuantIndex
9
+
10
+ def add: (::Array[::Float]) -> void
11
+ def add_with_dim: (::Array[::Float], ::Integer) -> void
12
+ def search: (::Array[::Float], ::Integer) -> SearchResults
13
+ def search_with_mask: (::Array[::Float], ::Integer, ::Array[bool]?) -> SearchResults
14
+ def prepare: () -> void
15
+ def write: (::String) -> void
16
+ def len: () -> ::Integer
17
+ def empty?: () -> bool
18
+ def dim: () -> ::Integer
19
+ def dim_opt: () -> ::Integer?
20
+ def bit_width: () -> ::Integer
21
+ def swap_remove: (::Integer) -> ::Integer
22
+ end
23
+
24
+ class SearchResults
25
+ def scores: () -> ::Array[::Float]
26
+ def indices: () -> ::Array[::Integer]
27
+ def nq: () -> ::Integer
28
+ def k: () -> ::Integer
29
+ def scores_for_query: (::Integer) -> ::Array[::Float]
30
+ def indices_for_query: (::Integer) -> ::Array[::Integer]
31
+ end
32
+
33
+ class IdMapIndex
34
+ def self.new: (::Integer, ::Integer) -> IdMapIndex
35
+ def self.new_lazy: (::Integer) -> IdMapIndex
36
+ def self.load: (::String) -> IdMapIndex
37
+
38
+ def add_with_ids: (::Array[::Float], ::Array[::Integer]) -> void
39
+ def add_with_ids_2d: (::Array[::Float], ::Integer, ::Array[::Integer]) -> void
40
+ def remove: (::Integer) -> bool
41
+ def search: (::Array[::Float], ::Integer) -> [::Array[::Float], ::Array[::Integer]]
42
+ def search_with_allowlist: (::Array[::Float], ::Integer, ::Array[::Integer]?) -> [::Array[::Float], ::Array[::Integer]]
43
+ def contains?: (::Integer) -> bool
44
+ def prepare: () -> void
45
+ def write: (::String) -> void
46
+ def len: () -> ::Integer
47
+ def empty?: () -> bool
48
+ def dim: () -> ::Integer
49
+ def dim_opt: () -> ::Integer?
50
+ def bit_width: () -> ::Integer
51
+ end
52
+ end
53
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-turbovec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sal Scotto
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ description: A Ruby gem that compiles a native Rust extension with magnus and rb-sys
27
+ to expose Turbovec APIs.
28
+ email:
29
+ - sal.scotto@gmail.com
30
+ executables: []
31
+ extensions:
32
+ - ext/ruby_llm/turbovec/extconf.rb
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".idea/.gitignore"
36
+ - ".idea/inspectionProfiles/Project_Default.xml"
37
+ - ".idea/jsLibraryMappings.xml"
38
+ - ".idea/misc.xml"
39
+ - ".idea/modules.xml"
40
+ - ".idea/ruby_llm-turbovec.iml"
41
+ - ".idea/vcs.xml"
42
+ - ".rspec"
43
+ - ".rubocop.yml"
44
+ - CHANGELOG.md
45
+ - CODE_OF_CONDUCT.md
46
+ - LICENSE.txt
47
+ - README.md
48
+ - Rakefile
49
+ - build_release.sh
50
+ - docs/api-coverage-matrix.md
51
+ - docs/release-process.md
52
+ - ext/ruby_llm/turbovec/Cargo.lock
53
+ - ext/ruby_llm/turbovec/Cargo.toml
54
+ - ext/ruby_llm/turbovec/Makefile
55
+ - ext/ruby_llm/turbovec/extconf.rb
56
+ - ext/ruby_llm/turbovec/mkmf.log
57
+ - ext/ruby_llm/turbovec/src/lib.rs
58
+ - lib/ruby_llm/turbovec.rb
59
+ - lib/ruby_llm/turbovec/version.rb
60
+ - sig/ruby_llm/turbovec.rbs
61
+ homepage: https://github.com/washu/ruby_llm-turbovec
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/washu/ruby_llm-turbovec
66
+ source_code_uri: https://github.com/washu/ruby_llm-turbovec
67
+ changelog_uri: https://github.com/washu/ruby_llm-turbovec/blob/main/CHANGELOG.md
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 3.1.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.6.9
83
+ specification_version: 4
84
+ summary: Native Ruby bindings for the Turbovec Rust library
85
+ test_files: []