phronomy 0.5.3 → 0.6.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.
@@ -28,6 +28,11 @@ module Phronomy
28
28
  # Recursion limit for graph execution (default: 25)
29
29
  attr_accessor :recursion_limit
30
30
 
31
+ # When true, workflow execution is driven by EventLoop instead of a
32
+ # synchronous loop in the calling thread. Defaults to false (sync mode).
33
+ # @see Phronomy::EventLoop
34
+ attr_accessor :event_loop
35
+
31
36
  # When true (default), user input and LLM output are recorded in trace spans.
32
37
  # Set to false in privacy-sensitive environments to prevent PII from reaching
33
38
  # the tracing backend (OTel, Langfuse, etc.).
@@ -37,6 +42,7 @@ module Phronomy
37
42
  @recursion_limit = 25
38
43
  @tracer = Phronomy::Tracing::NullTracer.new
39
44
  @trace_pii = true
45
+ @event_loop = false
40
46
  end
41
47
  end
42
48
  end
@@ -7,7 +7,6 @@ module Phronomy
7
7
  # Sub-modules are auto-loaded by Zeitwerk:
8
8
  # Phronomy::Context::TokenEstimator
9
9
  # Phronomy::Context::TokenBudget
10
- # Phronomy::Context::Builder
11
10
  module Context
12
11
  end
13
12
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Immutable event struct used for inter-FSM communication via EventLoop.
5
+ #
6
+ # @param type [Symbol] event identifier (:start, :state_completed,
7
+ # :finished, :halted, :error, or any user-defined name)
8
+ # @param target_id [String] FSMSession identifier — matches WorkflowContext#thread_id
9
+ # @param payload [Object] optional data attached to the event:
10
+ # - final/halted context for :finished/:halted
11
+ # - Exception for :error
12
+ # - nil for :start / :state_completed
13
+ Event = Data.define(:type, :target_id, :payload)
14
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Singleton event loop that manages all FSMSession instances.
5
+ #
6
+ # A single background thread reads from a global Thread::Queue and dispatches
7
+ # events to their target FSMSession. IO work (LLM calls, tool calls) runs in
8
+ # separate IO threads that post events back to the loop via EventLoop#post.
9
+ #
10
+ # Activated with: +Phronomy.configure { |c| c.event_loop = true }+
11
+ #
12
+ # == Fork safety
13
+ #
14
+ # +EventLoop.instance+ is lazily initialized. The background thread is not
15
+ # created until the first call, so Puma worker forking does not duplicate the
16
+ # thread. No +after_fork+ hook is required.
17
+ #
18
+ # == Deadlock warning
19
+ #
20
+ # Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
21
+ # entry action. The entry action runs on the EventLoop thread; a nested
22
+ # +invoke+ would block waiting for the same thread to process events →
23
+ # deadlock. Use the async IO pattern instead (spawn a Thread, post events
24
+ # back to the EventLoop).
25
+ class EventLoop
26
+ # Returns the singleton instance, creating and starting it on first call.
27
+ def self.instance
28
+ @instance ||= new.tap(&:start)
29
+ end
30
+
31
+ # Stops and destroys the singleton. Primarily used in tests.
32
+ # @api private
33
+ def self.reset!
34
+ @instance&.stop
35
+ @instance = nil
36
+ end
37
+
38
+ def initialize
39
+ @queue = Thread::Queue.new # global event queue (thread-safe; no Mutex needed)
40
+ @fsms = {} # { id => FSMSession } — EventLoop thread only
41
+ @waiting = {} # { id => completion_queue } — EventLoop thread only
42
+ end
43
+
44
+ # Registers an FSMSession for execution and returns a completion queue.
45
+ #
46
+ # The session and its completion queue are handed off to the EventLoop thread
47
+ # via the queue payload, so +@fsms+ and +@waiting+ are exclusively written
48
+ # and read by the EventLoop thread. No Mutex is required.
49
+ #
50
+ # The caller blocks on +completion_queue.pop+ to receive the final context
51
+ # (WorkflowContext) once the workflow finishes or halts. If an error occurred,
52
+ # the popped value will be an Exception — callers are responsible for re-raising it.
53
+ #
54
+ # @param fsm_session [Phronomy::FSMSession]
55
+ # @return [Thread::Queue] resolves to final/halted context, or an Exception
56
+ def register(fsm_session)
57
+ if Thread.current[:phronomy_event_loop_thread]
58
+ raise Phronomy::Error,
59
+ "Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
60
+ "entry action. Use the async IO pattern: spawn a Thread, post events " \
61
+ "back via Phronomy::EventLoop.instance.post(...) instead."
62
+ end
63
+
64
+ completion_queue = Thread::Queue.new
65
+ # Pass both session and completion_queue in the event payload so that the
66
+ # EventLoop thread is the sole writer of @fsms and @waiting.
67
+ @queue.push(Event.new(type: :start, target_id: fsm_session.id,
68
+ payload: {session: fsm_session, completion: completion_queue}))
69
+ completion_queue
70
+ end
71
+
72
+ # Enqueues an {AgentFSM} as a fire-and-forget child session.
73
+ #
74
+ # Unlike {#register}, this method:
75
+ # - Is safe to call from the EventLoop thread (entry actions).
76
+ # - Does NOT block — no completion queue is created.
77
+ # - Delegates `:finished`/`:error` cleanup to the EventLoop via posted events.
78
+ #
79
+ # @param agent_fsm [Phronomy::Agent::FSM]
80
+ # @return [nil]
81
+ def enqueue_child(agent_fsm)
82
+ @queue.push(Event.new(type: :start, target_id: agent_fsm.id,
83
+ payload: {session: agent_fsm, completion: nil}))
84
+ nil
85
+ end
86
+
87
+ # Posts an event to the loop. Safe to call from any thread (including IO threads).
88
+ #
89
+ # @param event [Phronomy::Event]
90
+ def post(event)
91
+ @queue.push(event)
92
+ end
93
+
94
+ # Starts the background event loop thread.
95
+ # @return [self]
96
+ def start
97
+ @running = true
98
+ @thread = Thread.new do
99
+ Thread.current[:phronomy_event_loop_thread] = true
100
+ run_loop
101
+ end
102
+ @thread.abort_on_exception = false
103
+ self
104
+ end
105
+
106
+ # Stops the background thread. Used in tests only.
107
+ # @api private
108
+ def stop
109
+ @running = false
110
+ @thread&.kill
111
+ @thread = nil
112
+ end
113
+
114
+ private
115
+
116
+ def run_loop
117
+ while @running
118
+ event = @queue.pop
119
+
120
+ case event.type
121
+ when :finished, :halted, :error
122
+ # All three terminal events share the same cleanup path.
123
+ # Both @fsms and @waiting are exclusively owned by this thread.
124
+ @fsms.delete(event.target_id)
125
+ cq = @waiting.delete(event.target_id)
126
+ cq&.push(event.payload)
127
+
128
+ when :start
129
+ # session and completion_queue arrive together in the payload so that
130
+ # this thread is the sole writer of @fsms and @waiting.
131
+ # completion may be nil for fire-and-forget child sessions (AgentFSM).
132
+ @fsms[event.target_id] = event.payload[:session]
133
+ cq = event.payload[:completion]
134
+ @waiting[event.target_id] = cq if cq
135
+ event.payload[:session].start
136
+
137
+ else
138
+ @fsms[event.target_id]&.handle(event)
139
+ end
140
+ end
141
+ rescue => e
142
+ # Unblock all waiting callers if the loop dies unexpectedly.
143
+ @waiting.values.each { |cq| cq.push(e) }
144
+ raise
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Event-driven execution wrapper for a single workflow run.
5
+ #
6
+ # Created by WorkflowRunner and registered with EventLoop. All public methods
7
+ # are called from the EventLoop thread — FSMSession is NOT thread-safe and must
8
+ # not be accessed concurrently from multiple threads.
9
+ #
10
+ # == Lifecycle
11
+ #
12
+ # register(session) → EventLoop posts :start → session.start
13
+ # ↓ (auto-transition present)
14
+ # EventLoop posts :state_completed → session.handle
15
+ # ↓ (repeat)
16
+ # session posts :finished or :halted
17
+ # ↓
18
+ # EventLoop pushes ctx to completion_queue → caller unblocks
19
+ #
20
+ # == Async IO pattern (EventLoop mode only)
21
+ #
22
+ # When a state has no auto-transition and is not a wait_state, but has an
23
+ # external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
24
+ # the FSMSession stays registered in the EventLoop and waits for that event.
25
+ # The entry action is expected to spawn an IO thread that posts the event back:
26
+ #
27
+ # entry :fetching, ->(ctx) {
28
+ # Thread.new {
29
+ # ctx.result = http.get(ctx.url)
30
+ # Phronomy::EventLoop.instance.post(
31
+ # Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
32
+ # )
33
+ # }
34
+ # }
35
+ # transition from: :fetching, on: :fetch_done, to: :process
36
+ class FSMSession
37
+ FINISH = WorkflowRunner::FINISH
38
+
39
+ # @return [String] workflow thread_id (matches WorkflowContext#thread_id)
40
+ attr_reader :id
41
+
42
+ # @param id [String]
43
+ # @param context [Object] includes Phronomy::WorkflowContext
44
+ # @param entry_point [Symbol] initial state name
45
+ # @param entry_actions [Hash] { state_name => [callable, ...] }
46
+ # @param auto_state_set [Hash] { state_name => true }
47
+ # @param declared_states [Array<Symbol>] all action state names
48
+ # @param wait_state_names [Array<Symbol>]
49
+ # @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
50
+ # @param phase_machine_class [Class] state_machines-backed phase tracker class
51
+ # @param recursion_limit [Integer]
52
+ # @param resume_event [Symbol, nil] external event to fire when resuming
53
+ # @param resume_phase [Symbol, nil] wait state name to resume from
54
+ def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
55
+ declared_states:, wait_state_names:, external_events:, phase_machine_class:,
56
+ recursion_limit:, resume_event: nil, resume_phase: nil)
57
+ @id = id
58
+ @ctx = context
59
+ @entry_point = entry_point
60
+ @entry_actions = entry_actions
61
+ @auto_state_set = auto_state_set
62
+ @declared_states = declared_states
63
+ @wait_state_names = wait_state_names
64
+ @external_events = external_events
65
+ @phase_machine_class = phase_machine_class
66
+ @recursion_limit = recursion_limit
67
+ @resume_event = resume_event
68
+ @resume_phase = resume_phase
69
+ @step = 0
70
+ @done = false
71
+ @current_state = nil
72
+ @tracker = nil
73
+ end
74
+
75
+ # Begins workflow execution. Called by EventLoop on :start event.
76
+ def start
77
+ if @resume_event
78
+ # Resume from wait state: position tracker at the wait state, then fire the
79
+ # external event. state_machines fires before_transition (exit) and
80
+ # after_transition (entry) callbacks, so both actions execute here.
81
+ @current_state = @resume_phase
82
+ @tracker = build_tracker(@current_state)
83
+ @tracker.context = @ctx
84
+ fire_and_advance!(@resume_event)
85
+ else
86
+ # Fresh start: state_machines does not fire callbacks on initialization,
87
+ # so we invoke the entry action for the initial state manually.
88
+ @current_state = @entry_point
89
+ @tracker = build_tracker(@current_state)
90
+ @tracker.context = @ctx
91
+ (@entry_actions[@current_state] || []).each { |c| c.call(@ctx) }
92
+ advance_or_halt
93
+ end
94
+ rescue => e
95
+ finish_with_error(e)
96
+ end
97
+
98
+ # Processes an event dispatched from EventLoop.
99
+ # Called for :state_completed and all user-defined external events.
100
+ #
101
+ # @param event [Phronomy::Event]
102
+ def handle(event)
103
+ return if @done
104
+
105
+ fire_and_advance!(event.type)
106
+ rescue => e
107
+ finish_with_error(e)
108
+ end
109
+
110
+ private
111
+
112
+ # Fires event_name on the phase tracker, updates @current_state, then
113
+ # calls advance_or_halt to decide what to do next.
114
+ def fire_and_advance!(event_name)
115
+ if @step >= @recursion_limit
116
+ raise Phronomy::RecursionLimitError,
117
+ "Recursion limit (#{@recursion_limit}) exceeded"
118
+ end
119
+
120
+ fire_event!(@tracker, event_name, @current_state)
121
+ next_phase = @tracker.phase.to_sym
122
+ # When next_phase == @current_state, no transition matched → treat as terminal.
123
+ @current_state = (next_phase == @current_state) ? FINISH : next_phase
124
+ @step += 1
125
+ advance_or_halt
126
+ end
127
+
128
+ # Determines the next action after the FSM has entered @current_state.
129
+ def advance_or_halt
130
+ return finish! if @current_state == FINISH
131
+
132
+ if @wait_state_names.include?(@current_state)
133
+ return halt!
134
+ end
135
+
136
+ if @auto_state_set.key?(@current_state)
137
+ event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
138
+ return
139
+ end
140
+
141
+ if has_external_event_from?(@current_state)
142
+ # Async IO pattern: the entry action spawned an IO thread that will post
143
+ # an external event back. Stay registered; do nothing here.
144
+ return
145
+ end
146
+
147
+ # No transition declared — validate the state is known, then treat as terminal.
148
+ unless @declared_states.include?(@current_state)
149
+ raise ArgumentError, "State #{@current_state.inspect} is not defined"
150
+ end
151
+
152
+ finish!
153
+ end
154
+
155
+ def finish!
156
+ @done = true
157
+ @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
158
+ event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
159
+ end
160
+
161
+ def halt!
162
+ @done = true
163
+ @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
164
+ event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
165
+ end
166
+
167
+ def finish_with_error(err)
168
+ @done = true
169
+ event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
170
+ end
171
+
172
+ def fire_event!(tracker, event_name, from_state)
173
+ return if tracker.send(event_name)
174
+
175
+ raise ArgumentError,
176
+ "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
177
+ "Ensure at least one guard matches or add a fallback (no-guard) transition."
178
+ end
179
+
180
+ def has_external_event_from?(state)
181
+ @external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
182
+ end
183
+
184
+ def build_tracker(from_state)
185
+ machine = @phase_machine_class.new
186
+ machine.instance_variable_set(:@phase, from_state.to_s)
187
+ machine
188
+ end
189
+
190
+ def event_loop
191
+ Phronomy::EventLoop.instance
192
+ end
193
+ end
194
+ end
@@ -133,7 +133,7 @@ module Phronomy
133
133
  @threshold = confidence_threshold.to_f
134
134
  @max_iterations = max_iterations.to_i
135
135
  @raise_if_untrusted = raise_if_untrusted
136
- @compiled_graph = nil
136
+ @compiled_workflow = nil
137
137
  end
138
138
 
139
139
  # Run the generator-verifier pipeline.
@@ -144,7 +144,7 @@ module Phronomy
144
144
  # @raise [Phronomy::LowConfidenceError] when +raise_if_untrusted:+ is +true+
145
145
  # and the result does not meet the confidence threshold
146
146
  def invoke(input, config: {})
147
- app = compiled_graph
147
+ app = compiled_workflow
148
148
  state = app.invoke({input: input}, config: config)
149
149
  confidence = combined_confidence(state)
150
150
  trusted = confidence >= @threshold
@@ -166,8 +166,8 @@ module Phronomy
166
166
  [(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
167
167
  end
168
168
 
169
- def compiled_graph
170
- @compiled_graph ||= build_workflow
169
+ def compiled_workflow
170
+ @compiled_workflow ||= build_workflow
171
171
  end
172
172
 
173
173
  def build_workflow
@@ -184,42 +184,42 @@ module Phronomy
184
184
  Phronomy::Workflow.define(PipelineState) do
185
185
  initial :draft
186
186
 
187
- state :draft, action: ->(state) {
187
+ state :draft
188
+ state :review
189
+ state :finalize
190
+
191
+ entry :draft, ->(state) {
188
192
  feedback = state.review_notes.last
189
193
  prompt = dpb.call(state.input, feedback)
190
194
  result = draft_agent.invoke(prompt)
191
195
  parsed = drp.call(result[:output])
192
- state.merge(
193
- draft: parsed[:answer].to_s,
194
- self_score: pipeline.__send__(:clamp, parsed[:confidence]),
195
- citations: pipeline.__send__(:normalize_citations, parsed[:citations]),
196
- iteration: state.iteration + 1
197
- )
196
+ state.draft = parsed[:answer].to_s
197
+ state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
198
+ state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
199
+ state.iteration = state.iteration + 1
198
200
  }
199
201
 
200
- state :review, action: ->(state) {
202
+ entry :review, ->(state) {
201
203
  prompt = rpb.call(state.input, state.draft, state.citations)
202
204
  result = review_agent.invoke(prompt)
203
205
  parsed = rrp.call(result[:output])
204
- state.merge(
205
- review_score: pipeline.__send__(:clamp, parsed[:score]),
206
- approved: parsed[:approved] == true,
207
- review_notes: parsed[:feedback].to_s
208
- )
206
+ state.review_score = pipeline.__send__(:clamp, parsed[:score])
207
+ state.approved = parsed[:approved] == true
208
+ state.review_notes << parsed[:feedback].to_s
209
209
  }
210
210
 
211
- state :finalize, action: ->(state) { state.merge(output: state.draft) }
211
+ entry :finalize, ->(state) { state.output = state.draft }
212
212
 
213
- after :draft, to: :review
214
- after :finalize, to: :__finish__
213
+ transition from: :draft, to: :review
214
+ transition from: :finalize, to: :__finish__
215
215
 
216
- event :route_review, from: :review,
216
+ transition from: :review,
217
217
  guard: ->(state) {
218
218
  confidence = [state.self_score || 0.0, state.review_score || 0.0].min
219
219
  (confidence >= threshold && state.approved) || state.iteration >= max_iter
220
220
  },
221
221
  to: :finalize
222
- event :route_review, from: :review, to: :draft
222
+ transition from: :review, to: :draft
223
223
  end
224
224
  end
225
225
 
@@ -5,4 +5,3 @@
5
5
  require_relative "guardrail/base"
6
6
  require_relative "guardrail/input_guardrail"
7
7
  require_relative "guardrail/output_guardrail"
8
- require_relative "guardrail/builtin"
@@ -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.6.0"
5
5
  end