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 +4 -4
- data/lib/phronomy/agent/orchestrator.rb +100 -19
- data/lib/phronomy/vector_store/base.rb +15 -0
- data/lib/phronomy/vector_store/in_memory.rb +11 -1
- data/lib/phronomy/vector_store/pgvector.rb +8 -2
- data/lib/phronomy/vector_store/redis_search.rb +16 -3
- data/lib/phronomy/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a820781cc6a8a1f5d2740f3eec3d7438e414755b6237e8617a3a38d6a3d3f7b1
|
|
4
|
+
data.tar.gz: 2de51efabc0fd0eff481aa2a58ee897e782b85bd78045feed9e6ba82917baeda
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
92
|
-
#
|
|
91
|
+
# Concurrency is bounded by +max_concurrency+; when nil all tasks run at
|
|
92
|
+
# once (original behaviour).
|
|
93
93
|
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
# @
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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]
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/phronomy/version.rb
CHANGED