phronomy 0.5.3 → 0.5.4

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: 49b20f3defaed56477f9f1ee375a450d26a770d004c052754cc5c045746587cc
4
- data.tar.gz: 1d2fe811e467c7d04208b82cc9d9ca5fca9b17d0bf6061aa952a2fb862c23a53
3
+ metadata.gz: a820781cc6a8a1f5d2740f3eec3d7438e414755b6237e8617a3a38d6a3d3f7b1
4
+ data.tar.gz: 2de51efabc0fd0eff481aa2a58ee897e782b85bd78045feed9e6ba82917baeda
5
5
  SHA512:
6
- metadata.gz: 763cf25297e0c8799ad76bcd362ecb5f1899a9ccd0d90791e119d2d0946c59f7c076f7a00d92e01e64735e90b45d7e1aa5462e41efceee9147daf45ac214551f
7
- data.tar.gz: 3a30e9198008dd9e4e512c374324b4c1cfda40c2a41762ff160f90ffa8ac98c0669f700d810e899fa661bd68af3a14db0145a00afc4d0e97d560d7de27989db0
6
+ metadata.gz: 5e06a07c3cb2af23be1018afc0ad006b9b161fc02df38762fa8635b48cd46e8f84d82a14178129e64ab115846034a29177d55d0960af73f40d602fd56caae85c
7
+ data.tar.gz: 3e96270b7752a531fb5c2f432fbd80217e421fb16ddac47ac94224e3b783412cb5ea747d07a1603f2827c830aecf351a394c77449f99a9303135f56b94c53449
@@ -88,31 +88,112 @@ module Phronomy
88
88
  # threads. Each task is a Hash describing one agent invocation.
89
89
  #
90
90
  # Results are returned in the same order as the input +tasks+ array.
91
- # If any thread raises an exception, the exception is re-raised in the
92
- # calling thread after all threads have completed (via +Thread#value+).
91
+ # Concurrency is bounded by +max_concurrency+; when nil all tasks run at
92
+ # once (original behaviour).
93
93
  #
94
- # @param tasks [Array<Hash>]
95
- # @option task [Class] :agent agent class to invoke (required)
96
- # @option task [String] :input input string for the agent (required)
97
- # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
98
- # @return [Array<Hash>] agent results in the same order as +tasks+
99
- def dispatch_parallel(*tasks)
100
- threads = tasks.map do |task|
101
- Thread.new do
102
- task[:agent].new.invoke(task[:input], config: task.fetch(:config, {}))
103
- end
94
+ # Error semantics are controlled by +on_error+:
95
+ # - +:raise+ (default) — every task runs to completion; the first
96
+ # exception in input order is then re-raised in the calling thread.
97
+ # - +:skip+ — failed tasks return +nil+; no exception is raised.
98
+ #
99
+ # @param tasks [Array<Hash>]
100
+ # @option task [Class] :agent agent class to invoke (required)
101
+ # @option task [String] :input input string for the agent (required)
102
+ # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
103
+ # @param max_concurrency [Integer, nil] maximum number of concurrent threads;
104
+ # nil means no limit (all tasks run simultaneously)
105
+ # @param on_error [Symbol] +:raise+ or +:skip+
106
+ # @return [Array<Hash, nil>] agent results in the same order as +tasks+
107
+ # @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
108
+ # @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
109
+ def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise)
110
+ unless [:raise, :skip].include?(on_error)
111
+ raise ArgumentError, "unknown on_error: #{on_error.inspect}"
104
112
  end
105
- threads.map(&:value)
113
+ if max_concurrency && !(max_concurrency.is_a?(Integer) && max_concurrency.positive?)
114
+ raise ArgumentError, "max_concurrency must be a positive Integer"
115
+ end
116
+
117
+ bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error)
106
118
  end
107
119
 
108
120
  # Runs the same agent against multiple inputs in parallel (fan-out pattern).
109
121
  #
110
- # @param agent [Class] agent class to invoke for every input
111
- # @param inputs [Array<String>] list of input strings
112
- # @param config [Hash] forwarded to every +agent#invoke+ call
113
- # @return [Array<Hash>] results in the same order as +inputs+
114
- def fan_out(agent:, inputs:, config: {})
115
- dispatch_parallel(*inputs.map { |input| {agent: agent, input: input, config: config} })
122
+ # Accepts the same +max_concurrency:+ and +on_error:+ keyword arguments as
123
+ # {#dispatch_parallel} and forwards them unchanged.
124
+ #
125
+ # @param agent [Class] agent class to invoke for every input
126
+ # @param inputs [Array<String>] list of input strings
127
+ # @param config [Hash] forwarded to every +agent#invoke+ call
128
+ # @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
129
+ # @param on_error [Symbol] forwarded to {#dispatch_parallel}
130
+ # @return [Array<Hash, nil>] results in the same order as +inputs+
131
+ def fan_out(agent:, inputs:, config: {}, max_concurrency: nil, on_error: :raise)
132
+ dispatch_parallel(
133
+ *inputs.map { |input| {agent: agent, input: input, config: config} },
134
+ max_concurrency: max_concurrency,
135
+ on_error: on_error
136
+ )
137
+ end
138
+
139
+ private
140
+
141
+ # Worker-pool implementation shared by {#dispatch_parallel} and {#fan_out}.
142
+ #
143
+ # Uses a +Queue+ as a work-stealing mechanism: each worker thread pops a
144
+ # task, executes it, and loops until the queue is empty. The number of
145
+ # workers is +min(max_concurrency, tasks.length)+, capped at the task count
146
+ # so we never spin up idle threads.
147
+ #
148
+ # +errors+ is indexed by task position so that the first error in *input*
149
+ # order is deterministically re-raised when +on_error: :raise+ is used.
150
+ # A +Mutex+ guards concurrent writes to +errors+ even though Array element
151
+ # assignment at different indices is safe in MRI; this keeps the code
152
+ # correct across alternative Ruby runtimes.
153
+ def bounded_map(tasks, max_concurrency:, on_error:)
154
+ return [] if tasks.empty?
155
+
156
+ results = Array.new(tasks.length)
157
+ errors = Array.new(tasks.length)
158
+ errors_mutex = Mutex.new
159
+
160
+ queue = Queue.new
161
+ tasks.each_with_index { |task, i| queue << [i, task] }
162
+
163
+ worker_count = [max_concurrency || tasks.length, tasks.length].min
164
+
165
+ workers = worker_count.times.map do
166
+ Thread.new do
167
+ loop do
168
+ i, task = begin
169
+ queue.pop(true)
170
+ rescue ThreadError
171
+ break # queue is empty; this worker is done
172
+ end
173
+
174
+ begin
175
+ results[i] = task[:agent].new.invoke(
176
+ task[:input],
177
+ config: task.fetch(:config, {})
178
+ )
179
+ rescue => e
180
+ case on_error
181
+ when :skip
182
+ results[i] = nil
183
+ else
184
+ errors_mutex.synchronize { errors[i] = e }
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ workers.each(&:join)
192
+
193
+ first_error = errors.compact.first
194
+ raise first_error if first_error
195
+
196
+ results
116
197
  end
117
198
  end
118
199
  end
@@ -36,6 +36,21 @@ module Phronomy
36
36
  def clear
37
37
  raise NotImplementedError, "#{self.class}#clear is not implemented"
38
38
  end
39
+
40
+ private
41
+
42
+ # Validates that embedding has the expected dimension.
43
+ # Raises ArgumentError if sizes differ.
44
+ # A nil expected_dimension is a no-op (dimension not yet established).
45
+ def validate_embedding_dimension!(embedding, expected_dimension)
46
+ return unless expected_dimension
47
+
48
+ actual = embedding.size
49
+ return if actual == expected_dimension
50
+
51
+ raise ArgumentError,
52
+ "Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
53
+ end
39
54
  end
40
55
  end
41
56
  end
@@ -12,14 +12,22 @@ module Phronomy
12
12
  # store.add(id: "1", embedding: [0.1, 0.9], metadata: { message: msg })
13
13
  # results = store.search(query_embedding: [0.1, 0.8], k: 3)
14
14
  class InMemory < Base
15
- def initialize
15
+ # @param dimension [Integer, nil] expected embedding dimension.
16
+ # When nil, the dimension is inferred from the first call to #add.
17
+ # For multi-threaded use, pass dimension: explicitly; concurrent first
18
+ # adds are not guaranteed to be race-free.
19
+ def initialize(dimension: nil)
16
20
  @documents = {}
21
+ @expected_dimension = dimension
17
22
  end
18
23
 
19
24
  # @param id [String]
20
25
  # @param embedding [Array<Float>]
21
26
  # @param metadata [Hash]
22
27
  def add(id:, embedding:, metadata: {})
28
+ # Establish expected dimension on first add, then validate.
29
+ @expected_dimension ||= embedding.size
30
+ validate_embedding_dimension!(embedding, @expected_dimension)
23
31
  @documents[id] = {embedding: embedding, metadata: metadata}
24
32
  self
25
33
  end
@@ -28,6 +36,8 @@ module Phronomy
28
36
  # @param k [Integer]
29
37
  # @return [Array<Hash>] sorted by descending score
30
38
  def search(query_embedding:, k: 5)
39
+ # search never establishes dimension; validate only when dimension is known.
40
+ validate_embedding_dimension!(query_embedding, @expected_dimension)
31
41
  # Take an atomic snapshot before iterating. Hash#dup is a C-level
32
42
  # call that completes without releasing the GVL, so it is atomic with
33
43
  # respect to any other Ruby thread. Iterating the copy instead of
@@ -18,8 +18,11 @@ module Phronomy
18
18
  # store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
19
19
  # results = store.search(query_embedding: [0.1, 0.8], k: 5)
20
20
  class Pgvector < Base
21
- # @param model_class [Class] ActiveRecord model with id/embedding/metadata columns
22
- def initialize(model_class:)
21
+ # @param model_class [Class] ActiveRecord model with id/embedding/metadata columns
22
+ # @param dimension [Integer, nil] expected embedding dimension for Phronomy-side
23
+ # pre-validation. When nil, dimension enforcement is delegated to the
24
+ # database schema; no pre-validation is performed by Phronomy.
25
+ def initialize(model_class:, dimension: nil)
23
26
  begin
24
27
  require "pgvector"
25
28
  rescue LoadError
@@ -28,12 +31,14 @@ module Phronomy
28
31
  "Add `gem 'pgvector'` to your Gemfile."
29
32
  end
30
33
  @model_class = model_class
34
+ @dimension = dimension
31
35
  end
32
36
 
33
37
  # @param id [String]
34
38
  # @param embedding [Array<Float>]
35
39
  # @param metadata [Hash]
36
40
  def add(id:, embedding:, metadata: {})
41
+ validate_embedding_dimension!(embedding, @dimension)
37
42
  @model_class.upsert(
38
43
  {id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
39
44
  unique_by: :id
@@ -45,6 +50,7 @@ module Phronomy
45
50
  # @param k [Integer]
46
51
  # @return [Array<Hash>] sorted by descending similarity score
47
52
  def search(query_embedding:, k: 5)
53
+ validate_embedding_dimension!(query_embedding, @dimension)
48
54
  vec = safe_vector_literal(query_embedding)
49
55
  k_safe = Integer(k)
50
56
  conn = @model_class.connection
@@ -25,7 +25,11 @@ module Phronomy
25
25
 
26
26
  # @param redis [Redis] configured Redis client
27
27
  # @param index_name [String] RediSearch index name
28
- # @param dimension [Integer, nil] vector dimension; auto-detected on first add
28
+ # @param dimension [Integer, nil] vector dimension; auto-detected on first add.
29
+ # When connecting to an **existing** RediSearch index, you MUST pass
30
+ # dimension: explicitly. Without it, a freshly constructed instance
31
+ # treats the index as uninitialized until #add is called, and #search
32
+ # silently returns [] in the meantime.
29
33
  def initialize(redis:, index_name: "phronomy_vectors", dimension: nil)
30
34
  begin
31
35
  require "redis"
@@ -45,7 +49,11 @@ module Phronomy
45
49
  # @param embedding [Array<Float>]
46
50
  # @param metadata [Hash]
47
51
  def add(id:, embedding:, metadata: {})
48
- ensure_index!(embedding.length)
52
+ # Establish expected dimension on first add (not race-free for concurrent
53
+ # first adds), then validate, then create/reuse the index.
54
+ @dimension ||= embedding.size
55
+ validate_embedding_dimension!(embedding, @dimension)
56
+ ensure_index!(@dimension)
49
57
  @redis.call(
50
58
  "HSET", "#{DOC_PREFIX}#{id}",
51
59
  "embedding", pack_vector(embedding),
@@ -58,7 +66,12 @@ module Phronomy
58
66
  # @param k [Integer]
59
67
  # @return [Array<Hash>] sorted by descending similarity score
60
68
  def search(query_embedding:, k: 5)
61
- ensure_index!(query_embedding.length)
69
+ # search never establishes dimension. If dimension is unknown and the
70
+ # index has not been created yet, there are no documents to return.
71
+ return [] if @dimension.nil? && !@index_created
72
+
73
+ validate_embedding_dimension!(query_embedding, @dimension)
74
+ ensure_index!(@dimension)
62
75
  k_safe = Integer(k)
63
76
  blob = pack_vector(query_embedding)
64
77
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.5.3"
4
+ VERSION = "0.5.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S