emb 0.4.0.pre1 → 0.4.0.pre4

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: 646506ae908e1f9db100c67c6d4e20dc6c6fc84e9d2d2a62c7954385ff632d73
4
- data.tar.gz: 07e42cf06b265483d14899ad8607909ad8caa99243e28bd7fe66dfad6eef1496
3
+ metadata.gz: 0c6f451b27da29d343a893e88a8ae949620e33213941a785088d0e66f1cbe8ab
4
+ data.tar.gz: 7eb7bc8fa4069bedc853d1580c5504a1a1fbbdff432ff458f61152487199de78
5
5
  SHA512:
6
- metadata.gz: 0ea5a3f7613be7280da47b7e52ebcff8b7773ef33ebbdcdbaa231e28084b8e4b3b12755be415b965b82ce395ea252c1dbf7592a4981740e2ba45b67e97338d49
7
- data.tar.gz: 869f1f0df61b44fc09aa6ce95b4b04bbcf0de1542f2697594160e16baf977dc6707df7a7a6da77a0e68be3d1f199c77f2cac171e0a41cabdf467c8958a352e89
6
+ metadata.gz: 94e720e10c56ccca0655c7dab9e28ac71d1731a4da5206664338be49de3dd2756f2acc56aa938a0b949f22dbe38927e2231abfee44cdf5339646079d8bf01fd0
7
+ data.tar.gz: 115a9d1814d895c856f39f6a557625cdb19abb59628dca39e314f35ace1c97c171c5a44c3c06c53500a6dee4332602f43bc4da04f6dabf0da053524ca43b6030
data/Gemfile CHANGED
@@ -9,6 +9,11 @@ gem 'redis-client'
9
9
  gem 'rake', require: false
10
10
  gem 'rspec', require: false
11
11
 
12
+ # Development-only: the Railtie integration suite boots a real Rails app so the
13
+ # real middleware-stack objects and boot order are exercised. Rails is never a
14
+ # runtime dependency of the gem. Select a line with RAILS_VERSION.
15
+ gem 'rails', ENV.fetch('RAILS_VERSION', '~> 8.0'), require: false
16
+
12
17
  gem 'rubocop', require: false
13
18
  gem 'rubocop-rake', require: false
14
19
  gem 'rubocop-rspec', require: false
data/README.md CHANGED
@@ -78,7 +78,7 @@ coalescing with `lazy: :multi` or concurrent fan-out with `lazy: :batch` — glo
78
78
  can take over a second of inference on a shared CPU, and redis-client's silent default is
79
79
  1.0s — a slower reply times out. The gem therefore defaults to an explicit 10s timeout
80
80
  and `reconnect_attempts: 0`: a failing batch fails closed after one attempt and raises
81
- `Emb::ServerError` (see [Lazy execution modes](#lazy-execution-modes)). Set
81
+ `Emb::ServerError` (see [Lazy batching](#lazy-batching)). Set
82
82
  `Emb.configure { |c| c.reconnect_attempts = 2 }` and redis-client re-sends
83
83
  **connection and protocol failures** up to that many extra times before the batch fails
84
84
  closed — each re-send re-runs server inference, so keep the budget small. Operation
@@ -291,6 +291,28 @@ exceptions (`RedisClient::CommandError`): read-only parameters (`listen`, `tls_*
291
291
  `models`), invalid values, and `NOAUTH` on password-protected servers are not
292
292
  swallowed.
293
293
 
294
+ ### Cache lifecycle commands
295
+
296
+ When server-side caching is enabled, the Ruby client exposes the lifecycle
297
+ commands on module, instance, pooled, and round-robin clients:
298
+
299
+ ```ruby
300
+ Emb.cache_flush # all models; => removed entry count
301
+ Emb.cache_flush(:minilm) # one model; => removed entry count
302
+ Emb.save_cache # => "OK" once the background save is accepted
303
+
304
+ Emb.stats[:cache_snapshot_in_progress] # 0 or 1
305
+ Emb.server_info(:cache) # completion, failure, restore limits
306
+ ```
307
+
308
+ Snapshot persistence is configured on the server with `cache_file`,
309
+ `cache_load`, `cache_save`, `cache_save_on_shutdown`, `cache_restore_limit`,
310
+ `cache_restore_reserve`, and `cache_save_rate_limit`. Automatic saves keep
311
+ serving inference and control commands while encoding and I/O run in the
312
+ background. Snapshot files include original input text and embeddings; use
313
+ protected/encrypted storage where appropriate and treat them as disposable
314
+ warm-start data rather than a durable database.
315
+
294
316
  ## Usage
295
317
 
296
318
  ### Single text
@@ -307,6 +329,32 @@ client = Emb.new(url: "redis://localhost:6379")
307
329
  result = client[:minilm]["hello world"]
308
330
  ```
309
331
 
332
+ ### VALUE format (RESP decimal reply)
333
+
334
+ By default the server replies with the compact float32 binary wire and the gem
335
+ unpacks it. Pass `format: :values` to send the RedisAI-style `VALUES` keyword
336
+ and get the server's self-describing envelope back: `dtype`, `shape`, and the
337
+ embedding values as decimal floats (the float64 widening the server stores, so
338
+ `unpack` is not needed). Single texts return the envelope with flat `values`;
339
+ multiple texts group the values per text (rows of `shape`).
340
+
341
+ ```ruby
342
+ Emb[:minilm]["hello world", format: :values]
343
+ # => { dtype: "FLOAT", shape: [1, 384], values: [0.0123, -0.0456, ...] }
344
+
345
+ Emb[:minilm]["hello", "world", format: :values]
346
+ # => { dtype: "FLOAT", shape: [2, 384], values: [[...], [...]] }
347
+ ```
348
+
349
+ The batch loaders accept the same keyword; mixed formats within one batch
350
+ raise `ArgumentError`.
351
+
352
+ > **RESP3 note:** the server emits the decimal values as typed RESP3 doubles when
353
+ > the connection negotiated `HELLO 3` and as decimal bulk strings otherwise.
354
+ > The gem speaks RESP2 (binary default, decimal `values` opt-in) and does not
355
+ > parse RESP3 replies itself — connect with a RESP3-capable client to use the
356
+ > typed doubles on the wire.
357
+
310
358
  ### Multiple texts
311
359
 
312
360
  ```ruby
@@ -336,17 +384,37 @@ client.multi do |m|
336
384
  end
337
385
  ```
338
386
 
339
- ### Lazy execution modes
387
+ ### Script replies
340
388
 
341
- Embed-call behavior is governed by a single `lazy` mode `false` (default, eager),
342
- `:multi` (defer and coalesce into one `EMB` for a single model / one `EMB.MULTI` for mixed scopes, serial), or `:batch` (defer and execute
343
- the coalesced chunk shares **concurrently**). The three are mutually exclusive.
389
+ `Emb.eval` / `Emb.evalsha` run a Lua script against a model (KEYS = texts,
390
+ ARGV = args) and parse replies through the RESP grammar hashes, arrays,
391
+ strings, errors. Unlike the embed path, a scripted reply has no fixed shape,
392
+ so packed vectors are **not** auto-decoded: a `float32_bytes` reply comes back
393
+ as the raw bulk String. Decode it per call with the `decode:` keyword:
344
394
 
345
- | mode | `Emb[:model][text]` | execution |
346
- |---|---|---|
347
- | `false` (default) | immediate `EMB` round trip | serial, per call |
348
- | `:multi` | deferred coalesces into one `EMB` (single model) or `EMB.MULTI` (mixed) | serial, one command at a time |
349
- | `:batch` | deferred → coalesces into `EMB`/`EMB.MULTI` chunks | **concurrent** chunk shares run in parallel |
395
+ ```ruby
396
+ sha = client.script.load(:siglip2, siglip_source)
397
+
398
+ # decode: :f32 the reply is a packed float32 vector (or a numeric array)
399
+ vec = client.evalsha(:siglip2, sha, ["a photo of a cat"], ["normalize"], decode: :f32)
400
+ # => [0.0123, -0.0456, ...] 768 floats
401
+
402
+ # multi-text replies decode each element
403
+ vecs = client.evalsha(:siglip2, sha, ["a", "b"], ["normalize"], decode: :f32)
404
+ # => [[0.0123, ...], [-0.0456, ...]]
405
+
406
+ # decode: {field => :f32} — structured replies decode a named field
407
+ out = client.evalsha(:siglip2, sha, ["a"], ["normalize"], decode: { embedding: :f32 })
408
+ # => {"dim" => 768, "embedding" => [0.0123, ...]}
409
+ ```
410
+
411
+ `decode:` defaults to `nil` (no decoding, exactly today's behavior). Supported
412
+ modes are `:f32` and `{field => :f32}`; anything else, or a reply that is not
413
+ actually a float vector / hash at the decodable position, raises
414
+ `ArgumentError`. A raw bulk can always be decoded by hand with
415
+ `reply.unpack("e*")`.
416
+
417
+ ### Lazy batching
350
418
 
351
419
  In `:batch` mode the shares fan out across the configured instances (one share per
352
420
  instance when the share count allows) or across the instance's pool connections when a
@@ -465,13 +533,22 @@ If your Gemfile loads `emb` before Rails is required (non-standard boot order),
465
533
  guarded railtie require in `emb.rb` is skipped — add `require "emb/railtie"` in
466
534
  `config/application.rb` right after `require "rails/all"` (or in an initializer).
467
535
 
468
- The middleware is also safe to mount manually in any Rack app (the Railtie
469
- skips insertion when it is already present):
536
+ The middleware is also safe to mount manually in any Rack app. In a Rails app,
537
+ set the opt-out as well so the Railtie does not add a second copy:
470
538
 
471
539
  ```ruby
540
+ # config/application.rb
541
+ config.emb.middleware = false
542
+ ```
543
+
544
+ ```ruby
545
+ # wherever you build the stack
472
546
  use Emb::Middleware
473
547
  ```
474
548
 
549
+ A duplicate mount is harmless — clearing the per-thread scope is idempotent — but
550
+ the opt-out keeps the stack clean and makes the intended wiring explicit.
551
+
475
552
  The scope is cleared even when the app raises, and a fresh scope starts
476
553
  automatically with the next request. Loaders created but never used within a
477
554
  request are dropped — the create-then-consume contract applies per request.
data/lib/emb/batch.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'batch_loader'
4
4
  require 'redis_client'
5
5
  require_relative 'batch_dispatch'
6
+ require_relative 'values_batch'
6
7
 
7
8
  module Emb
8
9
  extend BatchDispatch
@@ -45,11 +46,17 @@ module Emb
45
46
  # explicit receiver.
46
47
  private(*BatchDispatch.instance_methods(false))
47
48
 
48
- def build_batch_loader(client, model, text)
49
+ def build_batch_loader(client, model, text, format: :binary)
50
+ unless %i[binary values].include?(format)
51
+ raise ArgumentError, "unknown format #{format.inspect} (expected :binary or :values)"
52
+ end
53
+
49
54
  # default_value []: an item whose batch failed (fail-closed) resolves to
50
55
  # an empty vector collection instead of nil, so resolver methods like
51
56
  # `loader.sum` do not blow up with NoMethodError-on-nil.
52
- BatchLoader.for([client, model, text]).batch(default_value: [], key: BATCH_KEY, &BATCH_BLOCK)
57
+ # Items carry [client, model, text, format] so the dispatch knows how to
58
+ # shape and parse the reply.
59
+ BatchLoader.for([client, model, text, format]).batch(default_value: [], key: BATCH_KEY, &BATCH_BLOCK)
53
60
  end
54
61
 
55
62
  # Removes every pending item of the batch scope. batch-loader prunes
@@ -11,17 +11,37 @@ module Emb
11
11
  # the command may have been sent are terminal and propagate so the forcing
12
12
  # thread can fail closed.
13
13
  def dispatch_slice(client, slice)
14
- models = slice.map { |_, model, _| model }.uniq
14
+ models = slice.map { |_, model, _, _| model }.uniq
15
15
  args = models.size == 1 ? same_model_args(slice, models.first) : mixed_model_args(slice)
16
16
  Array(client.send_command(*args))
17
17
  end
18
18
 
19
19
  def same_model_args(slice, model)
20
- ['EMB', model.to_s, *slice.flat_map { |_, _, text| Array(text) }]
20
+ texts = slice.flat_map { |_, _, text, _| Array(text) }
21
+ if slice_format(slice) == :values
22
+ ['EMB', model.to_s, 'VALUES', *texts]
23
+ else
24
+ ['EMB', model.to_s, *texts]
25
+ end
21
26
  end
22
27
 
23
28
  def mixed_model_args(slice)
24
- ['EMB.MULTI', *slice.flat_map { |_, model, text| Array(text).flat_map { |t| [model.to_s, t] } }]
29
+ pairs = slice.flat_map { |_, model, text, _| Array(text).flat_map { |t| [model.to_s, t] } }
30
+ if slice_format(slice) == :values
31
+ ['EMB.MULTI', 'VALUES', *pairs]
32
+ else
33
+ ['EMB.MULTI', *pairs]
34
+ end
35
+ end
36
+
37
+ # slice_format: the batch item layout is [client, model, text, format];
38
+ # items created before the format slot existed default to :binary.
39
+ def slice_format(slice)
40
+ formats = slice.map { |item| item[3] }.uniq
41
+ if formats.size > 1
42
+ raise ArgumentError, "cannot mix :binary and :values formats in one batch"
43
+ end
44
+ formats.first || :binary
25
45
  end
26
46
 
27
47
  # Maps a slice's reply entries onto its items in deferral order. Runs on
@@ -31,14 +51,18 @@ module Emb
31
51
  # from a client-raised ProtocolError so it is not counted as a transport
32
52
  # retry).
33
53
  def resolve_slice(loader, slice, results)
34
- expected = slice.sum { |_, _, text| Array(text).size }
54
+ if slice_format(slice) == :values
55
+ return ValuesBatch.resolve(loader, slice, results)
56
+ end
57
+
58
+ expected = slice.sum { |_, _, text, _| Array(text).size }
35
59
  unless results.size >= expected
36
60
  raise ShortReplyError, "expected #{expected} reply entries, got #{results.size}"
37
61
  end
38
62
 
39
63
  offset = 0
40
64
  slice.each do |item|
41
- _, _, text = item
65
+ _, _, text, _ = item
42
66
  texts = Array(text)
43
67
  values = entry_values(results, offset, texts)
44
68
  offset += texts.size
data/lib/emb/commands.rb CHANGED
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'script_reply_decode'
4
+
3
5
  module Emb
4
6
  # Server-status and runtime-config command wrappers. They only depend on
5
7
  # #send_command, so they live in this module (included into Emb::Client)
6
8
  # rather than growing the client class.
7
9
  module Commands
10
+ include ScriptReplyDecode
11
+
8
12
  # EMB.STATS as a Symbol-keyed Hash. No client-side type layer: values are
9
13
  # exactly what the RESP decoder returned (Integer where the server sends
10
14
  # RESP integers, String otherwise).
@@ -19,8 +23,101 @@ module Emb
19
23
  parse_info(send_command('INFO', *sections.map(&:to_s)))
20
24
  end
21
25
 
26
+ # Evaluate a script against a model (EMB.EVAL). texts become the script's
27
+ # KEYS, args its ARGV. Replies are typed per the reply grammar: hash
28
+ # replies (flat field/value pair arrays under RESP2) become Ruby Hashes,
29
+ # nested values recurse. decode: opt-in float decoding — see
30
+ # parse_script_reply (decode: :f32 or decode: {field => :f32}).
31
+ def eval(model, script, texts, args = [], decode: nil)
32
+ texts = Array(texts)
33
+ schema = normalize_decode(decode)
34
+ reply = send_command('EMB.EVAL', model.to_s, script, texts.size, *texts.map(&:to_s), *args.map(&:to_s))
35
+ parse_script_reply(reply, multi: texts.size > 1, decode: schema)
36
+ end
37
+
38
+ # Evaluate a previously loaded script by SHA1 (EMB.EVSHA). See #eval.
39
+ def evalsha(model, sha, texts, args = [], decode: nil)
40
+ texts = Array(texts)
41
+ schema = normalize_decode(decode)
42
+ reply = send_command('EMB.EVSHA', model.to_s, sha, texts.size, *texts.map(&:to_s), *args.map(&:to_s))
43
+ parse_script_reply(reply, multi: texts.size > 1, decode: schema)
44
+ end
45
+
46
+ # EMB.SCRIPT subcommands: a small command object so the surface reads
47
+ # naturally (script.load / script.exists / script.flush).
48
+ def script
49
+ @script ||= ScriptCommands.new(self)
50
+ end
51
+
52
+ # EMB.SCRIPT LOAD/EXISTS/FLUSH.
53
+ class ScriptCommands
54
+ def initialize(client)
55
+ @client = client
56
+ end
57
+
58
+ # Compile, cache, and return the script's SHA1 for the model.
59
+ def load(model, source)
60
+ @client.send_command('EMB.SCRIPT', 'LOAD', model.to_s, source)
61
+ end
62
+
63
+ # Whether each SHA is cached for the model.
64
+ def exists(model, *shas)
65
+ @client.send_command('EMB.SCRIPT', 'EXISTS', model.to_s, *shas.map(&:to_s)).map { |flag| flag == 1 }
66
+ end
67
+
68
+ # Clear the model's cached scripts, or all models when omitted.
69
+ def flush(model = nil)
70
+ args = ['EMB.SCRIPT', 'FLUSH']
71
+ args << model.to_s if model
72
+ @client.send_command(*args)
73
+ end
74
+ end
75
+
76
+ # Remove all cached embeddings, or only entries belonging to +model+.
77
+ # Returns the server's integer removed-entry count.
78
+ def cache_flush(model = nil)
79
+ args = ['EMB.CACHE.FLUSH']
80
+ args << model.to_s unless model.nil?
81
+ send_command(*args)
82
+ end
83
+
84
+ # Ask the server to start an asynchronous cache snapshot. Completion and
85
+ # failures are observable through #stats or #server_info(:cache).
86
+ def save_cache
87
+ send_command('EMB.SAVE')
88
+ end
89
+
22
90
  private
23
91
 
92
+ # Converts a scripted reply (single value, or an array of per-text values
93
+ # for multi-text calls) through the hash grammar: a flat field/value pair
94
+ # array becomes a Hash, recursively. Pure even-length string lists are
95
+ # indistinguishable from two-field hashes on the RESP2 wire, so scripts
96
+ # that must return them should nest them (wrap the list in a table).
97
+ #
98
+ # decode is the normalized (see normalize_decode) opt-in float decoding:
99
+ # nil → no decoding, parsing exactly as before
100
+ # :f32 → each value position is unpack('e*')'d when
101
+ # it is a packed float bulk; numeric arrays
102
+ # pass through unchanged (element types kept)
103
+ # {field => :f32, ...} → after hash parsing, the named field(s) of
104
+ # each hash reply decode as :f32 (fields
105
+ # absent from a reply are left untouched)
106
+ def parse_script_reply(reply, multi:, decode: nil)
107
+ parsed = (multi ? reply : [reply]).map { |v| script_hash(v) }
108
+ decoded = apply_decode(parsed, decode)
109
+ multi ? decoded : decoded.first
110
+ end
111
+
112
+ def script_hash(value)
113
+ return value unless value.is_a?(Array) && value.size.even?
114
+
115
+ pairs = value.each_slice(2).to_a
116
+ return value unless pairs.all? { |field, _| field.is_a?(String) }
117
+
118
+ pairs.to_h { |field, val| [field, script_hash(val)] }
119
+ end
120
+
24
121
  # Parse Redis INFO section text into a nested Hash:
25
122
  # {Server: {redis_version: "0.2.4", uptime_secs: "7"}, Cache: {…}, …}
26
123
  # Section names and keys are Symbols; values pass through as the server
data/lib/emb/proxy.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'values_reply'
4
+
3
5
  module Emb
4
6
  class Proxy
5
7
  attr_reader :name
@@ -9,8 +11,20 @@ module Emb
9
11
  @name = name
10
12
  end
11
13
 
12
- def [](text, *texts)
13
- return Emb.build_batch_loader(@client, @name, texts.empty? ? text : [text, *texts]) if @client.lazy?
14
+ # Queries the model for one or more texts. format: :binary (default)
15
+ # returns the float32-unpacked vectors from the compact BLOB wire;
16
+ # format: :values sends the VALUES keyword and returns the decoded
17
+ # envelope — a Hash with dtype:/shape:/values: (Flats, flat for a single
18
+ # text, grouped per text when several are requested).
19
+ def [](text, *texts, format: :binary)
20
+ format = normalize_format(format)
21
+ if @client.lazy?
22
+ return Emb.build_batch_loader(@client, @name, texts.empty? ? text : [text, *texts], format: format)
23
+ end
24
+
25
+ if format == :values
26
+ return values_query(text, texts)
27
+ end
14
28
 
15
29
  set = Array(@client.send_command('EMB', @name.to_s, text, *texts))
16
30
  result = set.map { |entry| entry.unpack('e*') }
@@ -23,5 +37,23 @@ module Emb
23
37
  def inspect
24
38
  "#<Emb::Proxy #{@name}>"
25
39
  end
40
+
41
+ private
42
+
43
+ def normalize_format(format)
44
+ unless %i[binary values].include?(format)
45
+ raise ArgumentError, "unknown format #{format.inspect} (expected :binary or :values)"
46
+ end
47
+
48
+ format
49
+ end
50
+
51
+ def values_query(text, texts)
52
+ reply = @client.send_command('EMB', @name.to_s, 'VALUES', text, *texts)
53
+ envelope = Emb::ValuesReply.parse(reply)
54
+ return envelope if texts.empty?
55
+
56
+ envelope.merge(values: Emb::ValuesReply.rows(envelope[:shape], envelope[:values]))
57
+ end
26
58
  end
27
59
  end
data/lib/emb/railtie.rb CHANGED
@@ -13,9 +13,13 @@ if defined?(Rails::Railtie)
13
13
  config.emb.middleware = true
14
14
  config.emb.job_middleware = true
15
15
 
16
+ # Insertion is unconditional (opt-out excepted): during initialization the
17
+ # stack is a Rails::Configuration::MiddlewareStackProxy, which records
18
+ # operations but cannot be inspected. Duplicate insertion is safe because
19
+ # Emb::BatchScope clearing is idempotent, so manual mounts use the opt-out
20
+ # instead of relying on detection.
16
21
  initializer 'emb.middleware' do |app|
17
22
  next if config.emb.middleware == false
18
- next if app.middleware.include?(Emb::Middleware)
19
23
 
20
24
  app.middleware.use Emb::Middleware
21
25
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emb
4
+ # Opt-in float decoding for scripted replies — the decode: keyword on
5
+ # Emb::Commands#eval / #evalsha. Kept out of the Commands module so the
6
+ # command surface stays small. decode: is validated (normalize_decode) before
7
+ # any command is sent, then applied after reply parsing.
8
+ module ScriptReplyDecode
9
+ private
10
+
11
+ # Validates an (un-normalized) decode option and canonicalizes it. Raises
12
+ # ArgumentError for unknown modes so callers fail before any command is
13
+ # sent. nil and an empty hash both mean "no decoding".
14
+ def normalize_decode(decode)
15
+ case decode
16
+ when nil, :f32
17
+ decode
18
+ when Hash
19
+ schema = decode.each_with_object({}) do |(field, mode), acc|
20
+ unless mode == :f32
21
+ raise ArgumentError, "unsupported decode mode #{mode.inspect} for field #{field.inspect} (supported: :f32)"
22
+ end
23
+
24
+ acc[field.to_s] = :f32
25
+ end
26
+ schema.empty? ? nil : schema
27
+ else
28
+ raise ArgumentError, "unsupported decode mode #{decode.inspect} (supported: :f32 or {field => :f32})"
29
+ end
30
+ end
31
+
32
+ # Applies a normalized decode to the parsed (script_hash'ed) reply array.
33
+ def apply_decode(parsed, decode)
34
+ return parsed if decode.nil?
35
+
36
+ case decode
37
+ when :f32
38
+ parsed.each_with_index.map { |v, i| decode_f32(v, position(i)) }
39
+ when Hash
40
+ parsed.each_with_index.map { |v, i| decode_hash_fields(v, decode, position(i)) }
41
+ end
42
+ end
43
+
44
+ def position(index)
45
+ index.zero? ? 'the reply' : "element #{index + 1}"
46
+ end
47
+
48
+ # decode: :f32 — a float32-packed bulk unpacks to floats; a numeric array
49
+ # (the pre-float32_bytes reply style) passes through unchanged; anything
50
+ # else is a contract mismatch and raises.
51
+ def decode_f32(value, where)
52
+ case value
53
+ when String
54
+ unless (value.bytesize % 4).zero?
55
+ raise ArgumentError,
56
+ "decode :f32: #{where} is a #{value.bytesize}-byte bulk, not a multiple of 4 — not a float32 vector"
57
+ end
58
+
59
+ value.unpack('e*')
60
+ when Array
61
+ return value if value.all?(Numeric)
62
+
63
+ raise ArgumentError, "decode :f32: #{where} is an array of non-numbers, not a float vector"
64
+ else
65
+ raise ArgumentError,
66
+ "decode :f32: #{where} is a #{value.class}, expected a float32-packed bulk or a numeric array"
67
+ end
68
+ end
69
+
70
+ # decode: {field => :f32} — the reply (or each per-text reply) must be a
71
+ # hash; present fields decode as :f32, absent fields stay untouched.
72
+ def decode_hash_fields(value, schema, where)
73
+ unless value.is_a?(Hash)
74
+ raise ArgumentError, "decode #{schema.inspect}: #{where} is a #{value.class}, expected a hash reply"
75
+ end
76
+
77
+ schema.each_key do |field|
78
+ next unless value.key?(field)
79
+
80
+ value[field] = decode_f32(value[field], "field #{field.inspect} of #{where}")
81
+ end
82
+ value
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'values_reply'
4
+
5
+ module Emb
6
+ # VALUES-format batch resolution for the deferred path. Split out of
7
+ # BatchDispatch so both modules stay under the Metrics rubric.
8
+ module ValuesBatch
9
+ module_function
10
+
11
+ # Resolves a VALUES reply onto the slice's items. Single-model slices get
12
+ # ONE envelope reply carrying every text's flattened values (row-major),
13
+ # so the rows are re-sliced per item; mixed-model slices get per-pair
14
+ # envelopes and map one row each. Failures stay nil, mirroring the BLOB
15
+ # path's MGET semantics.
16
+ def resolve(loader, slice, results)
17
+ if single_model?(slice)
18
+ rows = envelope_rows(results)
19
+ expect_rows!(slice, rows.size)
20
+ assign_rows(loader, slice, rows)
21
+ else
22
+ resolve_entries(loader, slice, results)
23
+ end
24
+ end
25
+
26
+ def single_model?(slice)
27
+ slice.map { |item| item[1] }.uniq.size == 1
28
+ end
29
+
30
+ def envelope_rows(results)
31
+ envelope = Emb::ValuesReply.parse(rehydrate_envelope(results))
32
+ Emb::ValuesReply.rows(envelope[:shape], envelope[:values])
33
+ end
34
+
35
+ # Under RESP3, redis-client decodes the server's single-model VALUES map
36
+ # into a Ruby Hash, but dispatch_slice wraps every reply in Array(), which
37
+ # turns that Hash into an array of [key, value] pairs. Rehydrate those
38
+ # pairs back into a Hash so parse sees a real envelope; RESP2 flat pair
39
+ # arrays (top-level strings) pass through untouched.
40
+ def rehydrate_envelope(results)
41
+ return results.to_h if results.all? { |e| e.is_a?(Array) && e.size == 2 }
42
+
43
+ results
44
+ end
45
+
46
+ def expect_rows!(slice, got)
47
+ expected = slice.sum { |_, _, text, _| Array(text).size }
48
+ return if got >= expected
49
+
50
+ raise ShortReplyError, "expected #{expected} VALUES rows, got #{got}"
51
+ end
52
+
53
+ def assign_rows(loader, slice, rows)
54
+ offset = 0
55
+ slice.each do |item|
56
+ _, _, text, = item
57
+ texts = Array(text)
58
+ values = rows[offset, texts.size]
59
+ offset += texts.size
60
+ loader.call(item, values.size == 1 ? values.first : values)
61
+ end
62
+ end
63
+
64
+ def resolve_entries(loader, slice, results)
65
+ expect_entries!(slice, results.size)
66
+ offset = 0
67
+ slice.each do |item|
68
+ _, _, text, = item
69
+ texts = Array(text)
70
+ values = entry_values(results, offset, texts)
71
+ offset += texts.size
72
+ loader.call(item, values.size == 1 ? values.first : values)
73
+ end
74
+ end
75
+
76
+ # A short multi-model reply is a protocol violation: an explicit null slot
77
+ # is the only legal way to signal a missing pair (see the BLOB path).
78
+ def expect_entries!(slice, got)
79
+ expected = slice.sum { |_, _, text, _| Array(text).size }
80
+ return if got >= expected
81
+
82
+ raise ShortReplyError, "expected #{expected} VALUES reply entries, got #{got}"
83
+ end
84
+
85
+ def entry_values(results, offset, texts)
86
+ results[offset, texts.size].map { |entry| entry && row_of(entry) }
87
+ end
88
+
89
+ def row_of(entry)
90
+ envelope = Emb::ValuesReply.parse(entry)
91
+ Emb::ValuesReply.rows(envelope[:shape], envelope[:values]).first
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Emb
4
+ # Decoding for EMB VALUES envelopes (the RedisAI META+VALUES shape). The
5
+ # server replies with flat alternating key/value pairs under RESP2 — dtype,
6
+ # shape, values — where values are decimal bulk strings carrying the float64
7
+ # widening of each float32 dimension.
8
+ module ValuesReply
9
+ module_function
10
+
11
+ # Parses one envelope into a Hash. RESP2 replies arrive as a flat 6-element
12
+ # pair array; under `protocol: 3` redis-client already decoded the server's
13
+ # RESP3 map into a Ruby Hash (keys frozen strings, values already Floats),
14
+ # which is used directly. values are converted to Ruby Floats; an
15
+ # unparsable entry raises ArgumentError naming the offending value.
16
+ def parse(reply)
17
+ pairs = reply.is_a?(Hash) ? reply : reply.each_slice(2).to_h
18
+ shape = pairs.fetch('shape')
19
+ values = Array(pairs.fetch('values')).map { |v| float_value(v) }
20
+ { dtype: pairs.fetch('dtype'), shape: shape, values: values }
21
+ end
22
+
23
+ # rows reshapes flat values into per-text rows using shape [m, dim].
24
+ def rows(shape, values)
25
+ dim = shape[1].to_i
26
+ values.each_slice(dim).to_a
27
+ end
28
+
29
+ def float_value(v)
30
+ Float(v)
31
+ rescue ArgumentError, TypeError
32
+ raise ArgumentError, "EMB VALUES: unparsable decimal value #{v.inspect} (expected a numeric bulk string)"
33
+ end
34
+ end
35
+ end
data/lib/emb.rb CHANGED
@@ -36,14 +36,29 @@ module Emb
36
36
  def info(name) = default_client.info(name)
37
37
  def stats = default_client.stats
38
38
  def server_info(*sections) = default_client.server_info(*sections)
39
+ def cache_flush(model = nil) = default_client.cache_flush(model)
40
+ def save_cache = default_client.save_cache
39
41
  def config = default_client.config
40
42
  def help = default_client.help
41
43
  def ping = default_client.ping
42
44
  def ready = default_client.ready
43
45
  def ready? = default_client.ready?
44
46
  def multi(&) = default_client.multi(&)
47
+
48
+ def eval(model, script, texts, args = [], decode: nil)
49
+ default_client.eval(model, script, texts, args, decode: decode)
50
+ end
51
+
52
+ def evalsha(model, sha, texts, args = [], decode: nil)
53
+ default_client.evalsha(model, sha, texts, args, decode: decode)
54
+ end
55
+
56
+ def script = default_client.script
57
+
45
58
  def reset_registry! = default_client.reset_registry!
59
+
46
60
  def debug? = @debug
61
+
47
62
  def send_command(*) = default_client.send_command(*)
48
63
 
49
64
  def debug!
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: emb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0.pre1
4
+ version: 0.4.0.pre4
5
5
  platform: ruby
6
6
  authors:
7
7
  - elcuervo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-04 00:00:00.000000000 Z
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: batch-loader
@@ -63,6 +63,9 @@ files:
63
63
  - lib/emb/railtie.rb
64
64
  - lib/emb/round_robin_pool.rb
65
65
  - lib/emb/runtime_config.rb
66
+ - lib/emb/script_reply_decode.rb
67
+ - lib/emb/values_batch.rb
68
+ - lib/emb/values_reply.rb
66
69
  - lib/emb/version.rb
67
70
  homepage: https://github.com/elcuervo/emb
68
71
  licenses: