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.
@@ -1,193 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Graph
5
- # Represents a set of branches that execute concurrently as a single graph node.
6
- #
7
- # Each branch is a callable (Proc, lambda, or any object responding to #call)
8
- # that receives the current state and returns either a Hash of field updates or
9
- # nil. All branches run in separate threads; their results are merged in
10
- # registration order using the following policy:
11
- #
12
- # :replace fields — last-write-wins (rightmost branch wins)
13
- # :append fields — all Arrays are concatenated
14
- # :merge fields — all Hashes are deep-merged (rightmost wins on conflict)
15
- #
16
- # @example Basic two-branch node
17
- # graph.add_parallel_node(:step,
18
- # ->(state) { { field_a: "from_a" } },
19
- # ->(state) { { field_b: "from_b" } }
20
- # )
21
- #
22
- # @example With timeout and failure policy
23
- # graph.add_parallel_node(:step,
24
- # branch_a, branch_b,
25
- # timeout: 10,
26
- # on_error: :best_effort
27
- # )
28
- #
29
- # Timeout support
30
- # Pass +timeout:+ (seconds, Numeric) to limit how long the node may run.
31
- # If any thread does not finish within the limit, all threads are killed and
32
- # +Phronomy::Graph::TimeoutError+ is raised (for +:raise+ policy) or recorded
33
- # in the result hash under +:parallel_errors+ (for +:best_effort+ policy).
34
- #
35
- # Failure policies (+on_error:+)
36
- # :raise (default)
37
- # Re-raises the first exception after all threads are joined.
38
- # Mirrors the original Thread#value semantics.
39
- # :best_effort
40
- # Collects successful results and stores any errors in the update Hash
41
- # under the key +:parallel_errors+ (Array of exception objects).
42
- # The caller's state class should declare:
43
- # field :parallel_errors, type: :append, default: -> { [] }
44
- # Unknown keys are silently ignored by the State#merge machinery.
45
- class ParallelNode
46
- # @param branches [Array<#call>] at least one callable branch required
47
- # @param timeout [Numeric, nil] wall-clock limit in seconds; nil = unlimited
48
- # @param on_error [Symbol] :raise (default) or :best_effort
49
- def initialize(branches, timeout: nil, on_error: :raise)
50
- raise ArgumentError, "branches must be a non-empty Array" if branches.empty?
51
- unless %i[raise best_effort].include?(on_error)
52
- raise ArgumentError, "on_error must be :raise or :best_effort, got #{on_error.inspect}"
53
- end
54
-
55
- @branches = branches
56
- @timeout = timeout
57
- @on_error = on_error
58
- end
59
-
60
- # Executes all branches concurrently and merges their results.
61
- #
62
- # @param state [Object] state object (includes Phronomy::Graph::State)
63
- # @return [Hash, nil] merged update hash, or nil when all branches return nil
64
- def call(state)
65
- threads = @branches.map { |branch| Thread.new { branch.call(state) } }
66
- deadline = @timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout) : nil
67
- state_class = state.class
68
-
69
- if @on_error == :best_effort
70
- gather_best_effort(threads, deadline, state_class)
71
- else
72
- gather_raise(threads, deadline, state_class)
73
- end
74
- end
75
-
76
- private
77
-
78
- # Joins all threads, enforcing the deadline. Re-raises branch exceptions.
79
- def gather_raise(threads, deadline, state_class)
80
- if deadline
81
- threads.each do |t|
82
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
- next if t.join([remaining, 0].max)
84
-
85
- # Thread did not finish within the time limit.
86
- # Use Thread#raise instead of Thread#kill so that ensure blocks in
87
- # branches (DB connection return, Mutex release, etc.) are executed.
88
- timeout_error = Phronomy::Graph::TimeoutError.new(
89
- "parallel branch timed out after #{@timeout}s"
90
- )
91
- threads.each { |thr| thr.raise(timeout_error) unless thr.stop? }
92
- threads.each do |thr|
93
- thr.join(0.1)
94
- rescue
95
- nil
96
- end
97
- raise Phronomy::Graph::TimeoutError,
98
- "parallel branch timed out after #{@timeout}s"
99
- end
100
- end
101
-
102
- # All threads are done. Thread#value re-raises any stored exception.
103
- merge_results(threads.map(&:value), state_class)
104
- end
105
-
106
- # Joins all threads, collecting errors instead of re-raising them.
107
- def gather_best_effort(threads, deadline, state_class)
108
- errors = []
109
- results = threads.map do |t|
110
- if deadline
111
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
112
- # Thread#join(limit) re-raises the thread's stored exception in the calling
113
- # thread when the thread terminated abnormally within the limit.
114
- # Rescue here so the error is collected rather than propagated.
115
- begin
116
- joined = t.join([remaining, 0].max)
117
- rescue => e
118
- errors << e
119
- next nil
120
- end
121
- if joined.nil?
122
- timeout_error = Phronomy::Graph::TimeoutError.new(
123
- "branch timed out after #{@timeout}s"
124
- )
125
- t.raise(timeout_error) unless t.stop?
126
- begin
127
- t.join(0.1)
128
- rescue
129
- nil
130
- end
131
- errors << Phronomy::Graph::TimeoutError.new(
132
- "branch timed out after #{@timeout}s"
133
- )
134
- next nil
135
- end
136
- end
137
-
138
- begin
139
- t.value
140
- rescue => e
141
- errors << e
142
- nil
143
- end
144
- end
145
-
146
- merged = merge_results(results, state_class) || {}
147
- merged[:parallel_errors] = errors unless errors.empty?
148
- merged.empty? ? nil : merged
149
- end
150
-
151
- # Merges an Array of per-branch result Hashes (nils are skipped).
152
- # Field merge policy is determined from the State class field declarations:
153
- # :replace fields — last-write-wins (rightmost branch wins)
154
- # :append fields — all Arrays are concatenated
155
- # :merge fields — all Hashes are deep-merged (rightmost wins on conflict)
156
- # Unknown / undeclared fields fall back to type-based heuristics.
157
- def merge_results(results, state_class = nil)
158
- merged = results.compact.each_with_object({}) do |result, acc|
159
- next unless result.is_a?(Hash)
160
-
161
- result.each do |key, val|
162
- acc[key] = acc.key?(key) ? merge_values(acc[key], val, state_class&.fields&.dig(key, :type)) : val
163
- end
164
- end
165
-
166
- merged.empty? ? nil : merged
167
- end
168
-
169
- # Merges two values for the same state field key across branches.
170
- # Uses the declared field policy when available, otherwise falls back to
171
- # type-based heuristics (Array → concat, Hash → deep-merge, scalar → last-write-wins).
172
- def merge_values(old_val, new_val, policy = nil)
173
- case policy
174
- when :append
175
- (old_val.is_a?(Array) && new_val.is_a?(Array)) ? old_val + new_val : new_val
176
- when :merge
177
- (old_val.is_a?(Hash) && new_val.is_a?(Hash)) ? old_val.merge(new_val) : new_val
178
- when :replace
179
- new_val
180
- else
181
- # Unknown field or no State class: fall back to type-based heuristic.
182
- if old_val.is_a?(Array) && new_val.is_a?(Array)
183
- old_val + new_val
184
- elsif old_val.is_a?(Hash) && new_val.is_a?(Hash)
185
- old_val.merge(new_val)
186
- else
187
- new_val
188
- end
189
- end
190
- end
191
- end
192
- end
193
- end
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Graph
5
- # Module for defining graph state.
6
- # Include in a class and use the field DSL to declare state fields.
7
- #
8
- # Field update policies:
9
- # :replace (default) -- overwrites with the new value
10
- # :append -- appends to an Array
11
- # :merge -- deep-merges into a Hash
12
- #
13
- # @example
14
- # class MyState
15
- # include Phronomy::Graph::State
16
- # field :messages, type: :append, default: -> { [] }
17
- # field :query, type: :replace
18
- # field :metadata, type: :merge, default: -> { {} }
19
- # end
20
- module State
21
- def self.included(base)
22
- base.extend(ClassMethods)
23
- base.instance_variable_set(:@fields, {})
24
- end
25
-
26
- module ClassMethods
27
- # Defines a state field.
28
- # @param name [Symbol]
29
- # @param type [Symbol] :replace / :append / :merge
30
- # @param default [Object, Proc, nil]
31
- def field(name, type: :replace, default: nil)
32
- @fields[name] = {type: type, default: default}
33
- attr_accessor name
34
- end
35
-
36
- def fields
37
- @fields
38
- end
39
- end
40
-
41
- # Internal graph metadata accessors (not user-defined fields).
42
- # These are preserved through merge but excluded from to_h.
43
- attr_reader :thread_id, :current_nodes, :halted_before
44
-
45
- # Sets internal graph metadata. Returns self.
46
- # @param thread_id [String, nil]
47
- # @param current_nodes [Array<Symbol>]
48
- # @param halted_before [Boolean]
49
- def set_graph_metadata(thread_id: nil, current_nodes: [], halted_before: false)
50
- @thread_id = thread_id
51
- @current_nodes = current_nodes || []
52
- @halted_before = halted_before
53
- self
54
- end
55
-
56
- def initialize(**attrs)
57
- self.class.fields.each do |name, config|
58
- default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
59
- send(:"#{name}=", attrs.fetch(name, default))
60
- end
61
- @thread_id = nil
62
- @current_nodes = []
63
- @halted_before = false
64
- end
65
-
66
- # Immutably updates state fields. Returns a new instance with the applied changes.
67
- # Internal graph metadata (thread_id, current_nodes, halted_before) is preserved.
68
- # @param updates [Hash] { field_name => new_value }
69
- # @return [self.class] new state instance
70
- def merge(updates)
71
- new_attrs = {}
72
- self.class.fields.each_key do |name|
73
- field_config = self.class.fields[name]
74
- new_attrs[name] = if updates.key?(name)
75
- case field_config[:type]
76
- when :append
77
- Array(send(name)) + Array(updates[name])
78
- when :merge
79
- (send(name) || {}).merge(updates[name])
80
- else
81
- updates[name]
82
- end
83
- else
84
- send(name)
85
- end
86
- end
87
- new_state = self.class.new(**new_attrs)
88
- new_state.set_graph_metadata(
89
- thread_id: @thread_id,
90
- current_nodes: @current_nodes,
91
- halted_before: @halted_before
92
- )
93
- new_state
94
- end
95
-
96
- # Converts user-defined fields to a Hash (excludes internal graph metadata).
97
- # @return [Hash]
98
- def to_h
99
- self.class.fields.keys.each_with_object({}) do |name, h|
100
- h[name] = send(name)
101
- end
102
- end
103
- end
104
- end
105
- end
@@ -1,149 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Graph
5
- # Declarative agent workflow definition class.
6
- # Assembles nodes and edges, then returns an executable CompiledGraph via compile.
7
- class StateGraph
8
- START = :__start__
9
- FINISH = :__end__
10
-
11
- attr_reader :nodes, :edges, :entry_point
12
-
13
- # @param state_class [Class] class that includes Phronomy::Graph::State
14
- def initialize(state_class)
15
- @state_class = state_class
16
- @nodes = {}
17
- @edges = {}
18
- @conditional_edges = {}
19
- @entry_point = nil
20
- @before_callbacks = {}
21
- @after_callbacks = {}
22
- end
23
-
24
- # Adds a node.
25
- # @param name [Symbol]
26
- # @param callable [#call, nil] node execution logic (block accepted)
27
- # @return [self]
28
- def add_node(name, callable = nil, &block)
29
- @nodes[name] = callable || block
30
- self
31
- end
32
-
33
- # Adds a directed edge.
34
- # @param from [Symbol]
35
- # @param to [Symbol]
36
- # @param condition [Proc, nil] guard condition — receives state and returns truthy/falsy.
37
- # When nil, the edge is unconditional. When multiple edges exist from the same node,
38
- # they are evaluated in registration order and the first matching edge is taken.
39
- # @return [self]
40
- def add_edge(from, to, condition = nil)
41
- @edges[from] ||= []
42
- @edges[from] << {to: to, condition: condition}
43
- self
44
- end
45
-
46
- # Adds a conditional edge.
47
- # @param from [Symbol]
48
- # @param condition [Proc] receives state and returns the next node name
49
- # @param mapping [Hash, nil] maps condition return value to a node name (optional)
50
- # @return [self]
51
- def add_conditional_edges(from, condition, mapping = nil)
52
- @conditional_edges[from] = {condition: condition, mapping: mapping}
53
- self
54
- end
55
-
56
- # Sets the entry point node.
57
- # @param node_name [Symbol]
58
- # @return [self]
59
- def set_entry_point(node_name)
60
- @entry_point = node_name
61
- self
62
- end
63
-
64
- # Registers a callback to run before the given node executes.
65
- # Return :halt from the block to pause execution; any other value continues.
66
- # Callbacks registered here become defaults for every CompiledGraph produced by compile.
67
- # @param node [Symbol]
68
- # @yield [state] the current state
69
- # @return [self]
70
- def interrupt_before(node, &block)
71
- @before_callbacks[node] = block
72
- self
73
- end
74
-
75
- # Registers a callback to run after the given node completes.
76
- # Return :halt from the block to pause execution; any other value continues.
77
- # Callbacks registered here become defaults for every CompiledGraph produced by compile.
78
- # @param node [Symbol]
79
- # @yield [state] the state after the node ran
80
- # @return [self]
81
- def interrupt_after(node, &block)
82
- @after_callbacks[node] = block
83
- self
84
- end
85
-
86
- # Adds a parallel node that executes multiple branches concurrently.
87
- # Each branch callable receives the current state and must return a Hash or nil.
88
- # Results are merged in registration order (see ParallelNode for merge policy).
89
- #
90
- # @param name [Symbol]
91
- # @param branches [Array<#call>] at least one callable required
92
- # @param timeout [Numeric, nil] wall-clock limit in seconds (nil = unlimited)
93
- # @param on_error [Symbol] :raise (default) or :best_effort
94
- # @return [self]
95
- def add_parallel_node(name, *branches, timeout: nil, on_error: :raise)
96
- raise ArgumentError, "add_parallel_node requires at least one branch" if branches.empty?
97
-
98
- @nodes[name] = ParallelNode.new(branches, timeout: timeout, on_error: on_error)
99
- self
100
- end
101
-
102
- # Embeds a compiled subgraph as a single node in this graph.
103
- # The subgraph is invoked with a Hash derived from the parent state;
104
- # its final state Hash is returned and merged into the parent state.
105
- #
106
- # @param name [Symbol]
107
- # @param subgraph [CompiledGraph] the compiled subgraph to embed
108
- # @param input_mapper [Proc, nil] maps parent state → input Hash for the subgraph;
109
- # defaults to state.to_h (passes all parent fields)
110
- # @param output_mapper [Proc, nil] maps subgraph final state → Hash to merge back;
111
- # defaults to sub_state.to_h (passes all subgraph fields)
112
- # @return [self]
113
- def add_subgraph(name, subgraph, input_mapper: nil, output_mapper: nil)
114
- add_node(name) do |state|
115
- input = input_mapper ? input_mapper.call(state) : state.to_h
116
- sub_thread_id = "#{state.thread_id}/#{name}"
117
- sub_state = subgraph.invoke(input, config: {thread_id: sub_thread_id})
118
- output_mapper ? output_mapper.call(sub_state) : sub_state.to_h
119
- end
120
- end
121
-
122
- # Compiles the graph and returns a CompiledGraph.
123
- # Callbacks registered on StateGraph are inherited; additional callbacks can be
124
- # registered on the returned CompiledGraph to override or extend them.
125
- # @param state_store [Phronomy::StateStore::Base, nil] optional state store
126
- # to use for this compiled graph, overriding the global default.
127
- # @return [CompiledGraph]
128
- def compile(state_store: nil)
129
- if @entry_point.nil? && @nodes.size > 1
130
- raise ArgumentError,
131
- "set_entry_point was not called; call set_entry_point(:node_name) " \
132
- "before compile when the graph has multiple nodes"
133
- end
134
- CompiledGraph.new(
135
- state_class: @state_class,
136
- nodes: @nodes,
137
- edges: @edges,
138
- conditional_edges: @conditional_edges,
139
- entry_point: @entry_point || @nodes.keys.first,
140
- before_callbacks: @before_callbacks.dup,
141
- after_callbacks: @after_callbacks.dup,
142
- state_store: state_store
143
- )
144
- end
145
-
146
- attr_reader :conditional_edges
147
- end
148
- end
149
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "graph/state"
4
- require_relative "graph/parallel_node"
5
- require_relative "graph/state_graph"
6
- require_relative "graph/compiled_graph"
7
-
8
- module Phronomy
9
- module Graph
10
- # Raised when a parallel branch exceeds the wall-clock limit set via +timeout:+.
11
- class TimeoutError < Phronomy::Error; end
12
- end
13
- end