phronomy 0.1.4 → 0.2.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.
@@ -14,14 +14,13 @@ module Phronomy
14
14
  class InMemory < Base
15
15
  def initialize
16
16
  @documents = {}
17
- @mutex = Mutex.new
18
17
  end
19
18
 
20
19
  # @param id [String]
21
20
  # @param embedding [Array<Float>]
22
21
  # @param metadata [Hash]
23
22
  def add(id:, embedding:, metadata: {})
24
- @mutex.synchronize { @documents[id] = {embedding: embedding, metadata: metadata} }
23
+ @documents[id] = {embedding: embedding, metadata: metadata}
25
24
  self
26
25
  end
27
26
 
@@ -29,8 +28,7 @@ module Phronomy
29
28
  # @param k [Integer]
30
29
  # @return [Array<Hash>] sorted by descending score
31
30
  def search(query_embedding:, k: 5)
32
- snapshot = @mutex.synchronize { @documents.dup }
33
- results = snapshot.map do |id, doc|
31
+ results = @documents.map do |id, doc|
34
32
  score = cosine_similarity(query_embedding, doc[:embedding])
35
33
  {id: id, score: score, metadata: doc[:metadata]}
36
34
  end
@@ -38,18 +36,18 @@ module Phronomy
38
36
  end
39
37
 
40
38
  def remove(id:)
41
- @mutex.synchronize { @documents.delete(id) }
39
+ @documents.delete(id)
42
40
  self
43
41
  end
44
42
 
45
43
  def clear
46
- @mutex.synchronize { @documents.clear }
44
+ @documents.clear
47
45
  self
48
46
  end
49
47
 
50
48
  # @return [Integer] number of documents stored
51
49
  def size
52
- @mutex.synchronize { @documents.size }
50
+ @documents.size
53
51
  end
54
52
 
55
53
  private
@@ -38,7 +38,7 @@ module Phronomy
38
38
  @index_name = index_name
39
39
  @dimension = dimension
40
40
  @index_created = false
41
- @mutex = Mutex.new
41
+ @actor = Phronomy::Actor.new
42
42
  end
43
43
 
44
44
  # @param id [String]
@@ -80,7 +80,7 @@ module Phronomy
80
80
  end
81
81
 
82
82
  def clear
83
- @mutex.synchronize do
83
+ @actor.call do
84
84
  begin
85
85
  @redis.call("FT.DROPINDEX", @index_name, "DD")
86
86
  rescue => e
@@ -94,10 +94,8 @@ module Phronomy
94
94
  private
95
95
 
96
96
  def ensure_index!(dim)
97
- return if @index_created # fast path outside lock
98
-
99
- @mutex.synchronize do
100
- return if @index_created # re-check inside lock
97
+ @actor.call do
98
+ next if @index_created
101
99
 
102
100
  @dimension ||= dim
103
101
  begin
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workflow_runner"
4
+ require_relative "runnable"
5
+
6
+ module Phronomy
7
+ # StateChart-style workflow definition DSL.
8
+ #
9
+ # Defines agent workflows in terms of *states* and *events* backed by
10
+ # Phronomy::WorkflowRunner. This is the primary high-level API
11
+ # for graph-based execution in phronomy.
12
+ #
13
+ # == Basic usage
14
+ #
15
+ # app = Phronomy::Workflow.define(MyContext) do
16
+ # initial :fetch
17
+ #
18
+ # state :fetch, action: FETCH_NODE
19
+ # state :process, action: PROCESS_NODE
20
+ #
21
+ # after :fetch, to: :process
22
+ # after :process, to: :__finish__
23
+ # end
24
+ #
25
+ # result = app.invoke({ url: "https://example.com" })
26
+ #
27
+ # == Wait states
28
+ #
29
+ # app = Phronomy::Workflow.define(MyContext) do
30
+ # initial :propose
31
+ #
32
+ # state :propose, action: PROPOSE_NODE
33
+ # wait_state :awaiting_approval
34
+ # state :execute, action: EXECUTE_NODE
35
+ #
36
+ # after :propose, to: :awaiting_approval
37
+ # after :execute, to: :__finish__
38
+ #
39
+ # event :approve, from: :awaiting_approval, to: :execute
40
+ # event :reject, from: :awaiting_approval, to: :propose
41
+ # end
42
+ #
43
+ # halted = app.invoke({ ... })
44
+ # final = app.send_event(state: halted, event: :approve)
45
+ #
46
+ # == Conditional transitions
47
+ #
48
+ # event :route, from: :decide, guard: ->(s) { s.score > 5 }, to: :high
49
+ # event :route, from: :decide, to: :low # fallback (no guard)
50
+ #
51
+ class Workflow
52
+ include Phronomy::Runnable
53
+
54
+ # Defines a new Workflow.
55
+ # @param context_class [Class] class that includes Phronomy::WorkflowContext
56
+ # @yield block evaluated in DSL context
57
+ # @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
58
+ def self.define(context_class, &block)
59
+ builder = Builder.new(context_class)
60
+ builder.instance_eval(&block)
61
+ builder.build
62
+ end
63
+
64
+ # @param runner [Phronomy::WorkflowRunner]
65
+ def initialize(runner)
66
+ @runner = runner
67
+ end
68
+
69
+ # Executes the workflow from the initial state.
70
+ # @param input [Hash] initial context field values
71
+ # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
72
+ # @return [Object] final context
73
+ def invoke(input, config: {})
74
+ @runner.invoke(input, config: config)
75
+ end
76
+
77
+ # Resumes a halted workflow. Generic resume that works for all halt types.
78
+ # @param state [Object] halted context
79
+ # @param input [Hash, nil] optional field updates to merge before resuming
80
+ # @return [Object] final context
81
+ def resume(state:, input: nil)
82
+ @runner.resume(state: state, input: input)
83
+ end
84
+
85
+ # Fires a named event to advance a halted workflow.
86
+ # @param state [Object] halted context
87
+ # @param event [Symbol] event name (e.g. :approve, :reject, :resume)
88
+ # @param input [Hash, nil] optional field updates to merge before resuming
89
+ # @return [Object] final context
90
+ def send_event(state:, event:, input: nil)
91
+ @runner.send_event(state: state, event: event, input: input)
92
+ end
93
+
94
+ # Streaming execution. Yields { node: Symbol, state: Object } after each node.
95
+ # @param input [Hash]
96
+ # @param config [Hash]
97
+ # @yield [Hash]
98
+ # @return [Object] final context
99
+ def stream(input, config: {}, &block)
100
+ @runner.stream(input, config: config, &block)
101
+ end
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Internal DSL builder
105
+ # ---------------------------------------------------------------------------
106
+
107
+ # DSL builder for Phronomy::Workflow.define.
108
+ # Collects state/event/transition declarations and produces a WorkflowRunner.
109
+ class Builder
110
+ FINISH = Phronomy::WorkflowRunner::FINISH
111
+
112
+ def initialize(context_class)
113
+ @context_class = context_class
114
+ @initial = nil
115
+ # { node_name => callable }
116
+ @states = {}
117
+ # Array of { from:, to: } — auto-transitions after a state action
118
+ @after_transitions = []
119
+ # Array of { name:, from:, to:, guard: } — event-driven transitions
120
+ @event_transitions = []
121
+ # Set of wait state names
122
+ @wait_state_names = []
123
+ end
124
+
125
+ # Declares the initial (entry) state.
126
+ # @param state_name [Symbol]
127
+ # rubocop:disable Style/TrivialAccessors
128
+ def initial(state_name)
129
+ @initial = state_name
130
+ end
131
+ # rubocop:enable Style/TrivialAccessors
132
+
133
+ # Declares an action state.
134
+ # @param name [Symbol] state name
135
+ # @param action [#call, nil] callable invoked when entering the state.
136
+ # If nil, the state is treated as a no-op pass-through.
137
+ def state(name, action: nil)
138
+ @states[name] = action || ->(s) { s }
139
+ end
140
+
141
+ # Declares a wait state that automatically halts execution when reached.
142
+ # No action is registered; the workflow pauses here until an event resumes it.
143
+ # @param name [Symbol] wait state name (conventionally :awaiting_something)
144
+ def wait_state(name)
145
+ @wait_state_names << name
146
+ end
147
+
148
+ # Declares an automatic transition that fires after a state's action completes.
149
+ # @param from [Symbol] source state name
150
+ # @param to [Symbol] destination state name or :__finish__
151
+ def after(from, to:)
152
+ dest = (to == :__finish__) ? FINISH : to
153
+ @after_transitions << {from: from, to: dest}
154
+ end
155
+
156
+ # Declares an event-driven transition.
157
+ # When +guard:+ is provided, the transition is taken only if the guard
158
+ # returns truthy for the current context. Multiple events with the same
159
+ # name and source are evaluated in declaration order; the first passing
160
+ # guard wins.
161
+ # @param name [Symbol] event name
162
+ # @param from [Symbol] source state where this event can be fired
163
+ # @param to [Symbol] destination state or :__finish__
164
+ # @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
165
+ def event(name, from:, to:, guard: nil)
166
+ dest = (to == :__finish__) ? FINISH : to
167
+ @event_transitions << {name: name, from: from, to: dest, guard: guard}
168
+ end
169
+
170
+ # Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
171
+ def build
172
+ nodes = @states.dup
173
+ edges = build_edges
174
+ conditional_edges = build_conditional_edges
175
+ wait_states = build_wait_states
176
+
177
+ runner = Phronomy::WorkflowRunner.new(
178
+ state_class: @context_class,
179
+ nodes: nodes,
180
+ edges: edges,
181
+ conditional_edges: conditional_edges,
182
+ entry_point: @initial || nodes.keys.first,
183
+ wait_states: wait_states
184
+ )
185
+
186
+ Workflow.new(runner)
187
+ end
188
+
189
+ private
190
+
191
+ # Converts @after_transitions and non-guarded @event_transitions into
192
+ # the edges hash expected by WorkflowRunner: { from => [{to:, condition:}] }
193
+ #
194
+ # Event transitions whose from-node also has guarded transitions are omitted
195
+ # here; they are handled inside build_conditional_edges as a fallback.
196
+ def build_edges
197
+ edges = {}
198
+
199
+ # After-transitions (unconditional edges fired after action completes)
200
+ @after_transitions.each do |t|
201
+ edges[t[:from]] ||= []
202
+ edges[t[:from]] << {to: t[:to], condition: nil}
203
+ end
204
+
205
+ # Collect from-nodes that already have at least one guarded event
206
+ from_with_guards = @event_transitions.select { |t| t[:guard] }.map { |t| t[:from] }.to_set
207
+
208
+ # Unconditional event transitions are plain edges ONLY when no guarded
209
+ # event exists from the same source node. When guards are present the
210
+ # unguarded transition acts as a fallback and is wired inside
211
+ # build_conditional_edges instead.
212
+ @event_transitions.reject { |t| t[:guard] }.each do |t|
213
+ next if from_with_guards.include?(t[:from])
214
+ edges[t[:from]] ||= []
215
+ edges[t[:from]] << {to: t[:to], condition: nil}
216
+ end
217
+
218
+ edges
219
+ end
220
+
221
+ # Converts guarded event transitions into the conditional_edges hash:
222
+ # { from => { condition: Proc, mapping: nil } }
223
+ #
224
+ # Multiple guarded transitions from the same source are combined into a
225
+ # single routing proc. An unguarded transition from the same source is
226
+ # used as an automatic fallback when all guards fail.
227
+ def build_conditional_edges
228
+ conditional_edges = {}
229
+
230
+ guarded = @event_transitions.select { |t| t[:guard] }
231
+ guarded.group_by { |t| t[:from] }.each do |from, transitions|
232
+ # Unguarded fallback for this from-node (may be nil)
233
+ fallback = @event_transitions.find { |t| t[:from] == from && t[:guard].nil? }
234
+
235
+ routing = lambda do |state|
236
+ matched = transitions.find { |t| t[:guard].call(state) }
237
+ next matched[:to] if matched
238
+ fallback&.fetch(:to)
239
+ end
240
+ conditional_edges[from] = {condition: routing, mapping: nil}
241
+ end
242
+
243
+ conditional_edges
244
+ end
245
+
246
+ # Converts wait_state declarations plus event-driven transitions *to*
247
+ # wait states into the wait_states hash:
248
+ # { wait_state_name => { resume_event: Symbol, resume_to: Symbol } }
249
+ #
250
+ # For each wait state, we look for the first event declared as
251
+ # `event :X, from: :wait_state_name, to: :Y` and use that as the
252
+ # resume_event / resume_to pair. If multiple events exist for the same
253
+ # wait state, subsequent ones are registered as additional named events.
254
+ def build_wait_states
255
+ wait_states = {}
256
+
257
+ @wait_state_names.each do |ws|
258
+ # Find events that originate from this wait state
259
+ outgoing = @event_transitions.select { |t| t[:from] == ws }
260
+ primary = outgoing.first
261
+
262
+ wait_states[ws] = {
263
+ resume_event: primary&.fetch(:name),
264
+ resume_to: primary&.fetch(:to)
265
+ }
266
+
267
+ # Additional events from the same wait state are also registered so
268
+ # that send_event(:other_event) works for branching wait states.
269
+ outgoing.drop(1).each do |t|
270
+ wait_states[:"#{ws}__#{t[:name]}"] = {
271
+ resume_event: t[:name],
272
+ resume_to: t[:to]
273
+ }
274
+ end
275
+ end
276
+
277
+ wait_states
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Module for defining workflow context (the data that travels through a workflow).
5
+ # Include in a class and use the field DSL to declare context fields.
6
+ #
7
+ # In StateChart terminology this is the "extended state" or "context" —
8
+ # data associated with the current execution that does not affect transitions
9
+ # directly, as opposed to the current phase (which is the machine's state).
10
+ #
11
+ # Field update policies:
12
+ # :replace (default) -- overwrites with the new value
13
+ # :append -- appends to an Array
14
+ # :merge -- deep-merges into a Hash
15
+ #
16
+ # @example
17
+ # class ScanContext
18
+ # include Phronomy::WorkflowContext
19
+ # field :messages, type: :append, default: -> { [] }
20
+ # field :query, type: :replace
21
+ # field :metadata, type: :merge, default: -> { {} }
22
+ # end
23
+ module WorkflowContext
24
+ def self.included(base)
25
+ base.extend(ClassMethods)
26
+ base.instance_variable_set(:@fields, {})
27
+ end
28
+
29
+ module ClassMethods
30
+ # Defines a context field.
31
+ # @param name [Symbol]
32
+ # @param type [Symbol] :replace / :append / :merge
33
+ # @param default [Object, Proc, nil]
34
+ def field(name, type: :replace, default: nil)
35
+ @fields[name] = {type: type, default: default}
36
+ attr_accessor name
37
+ end
38
+
39
+ def fields
40
+ @fields
41
+ end
42
+ end
43
+
44
+ # Internal workflow metadata accessors (not user-defined fields).
45
+ # These are preserved through merge but excluded from to_h.
46
+ attr_reader :thread_id
47
+
48
+ # Returns the current execution phase of the workflow.
49
+ # Encoding:
50
+ # :__end__ — workflow completed (or not yet started)
51
+ # :awaiting_<name> — halted at a wait_state(:awaiting_<name>) declaration
52
+ # :<node> — resuming at <node> (workflow paused before its execution)
53
+ # @return [Symbol]
54
+ def phase
55
+ @phase || :__end__
56
+ end
57
+
58
+ # Returns true if the workflow is paused mid-execution (not yet completed).
59
+ # @return [Boolean]
60
+ def halted?
61
+ phase != :__end__
62
+ end
63
+
64
+ # Sets internal workflow metadata. Returns self.
65
+ # @param thread_id [String, nil]
66
+ # @param phase [Symbol, nil]
67
+ def set_graph_metadata(thread_id: nil, phase: nil)
68
+ @thread_id = thread_id unless thread_id.nil?
69
+ @phase = phase unless phase.nil?
70
+ self
71
+ end
72
+
73
+ def initialize(**attrs)
74
+ self.class.fields.each do |name, config|
75
+ default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
76
+ send(:"#{name}=", attrs.fetch(name, default))
77
+ end
78
+ @thread_id = nil
79
+ @phase = :__end__
80
+ end
81
+
82
+ # Immutably updates context fields. Returns a new instance with the applied changes.
83
+ # Internal workflow metadata (thread_id, phase) is preserved.
84
+ # @param updates [Hash] { field_name => new_value }
85
+ # @return [self.class] new context instance
86
+ def merge(updates)
87
+ new_attrs = {}
88
+ self.class.fields.each_key do |name|
89
+ field_config = self.class.fields[name]
90
+ new_attrs[name] = if updates.key?(name)
91
+ case field_config[:type]
92
+ when :append
93
+ Array(send(name)) + Array(updates[name])
94
+ when :merge
95
+ (send(name) || {}).merge(updates[name])
96
+ else
97
+ updates[name]
98
+ end
99
+ else
100
+ send(name)
101
+ end
102
+ end
103
+ new_context = self.class.new(**new_attrs)
104
+ new_context.set_graph_metadata(
105
+ thread_id: @thread_id,
106
+ phase: @phase
107
+ )
108
+ new_context
109
+ end
110
+
111
+ # Converts user-defined fields to a Hash (excludes internal workflow metadata).
112
+ # @return [Hash]
113
+ def to_h
114
+ self.class.fields.keys.each_with_object({}) do |name, h|
115
+ h[name] = send(name)
116
+ end
117
+ end
118
+ end
119
+ end