stellwerk-ruby 0.1.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.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Stellwerk
6
+ # Convenience wrapper for loading and executing compiled flows
7
+ class Flow
8
+ attr_reader :compiled_json, :path
9
+
10
+ # Load a flow from a JSON file
11
+ #
12
+ # @param path [String] Path to the compiled JSON file
13
+ # @return [Stellwerk::Flow]
14
+ # @raise [InvalidFlowError] if the file doesn't exist or contains invalid JSON
15
+ def self.load(path)
16
+ unless File.exist?(path)
17
+ raise InvalidFlowError, "Flow file not found: #{path}"
18
+ end
19
+
20
+ content = File.read(path)
21
+ json = JSON.parse(content)
22
+
23
+ new(json, path: path)
24
+ rescue JSON::ParserError => e
25
+ raise InvalidFlowError, "Invalid JSON in flow file: #{e.message}"
26
+ end
27
+
28
+ # Create a flow from a parsed JSON hash
29
+ #
30
+ # @param compiled_json [Hash] The compiled flow JSON
31
+ # @param path [String, nil] Optional path for debugging
32
+ def initialize(compiled_json, path: nil)
33
+ @compiled_json = compiled_json
34
+ @path = path
35
+ validate!
36
+ end
37
+
38
+ # Execute the flow with the given parameters
39
+ #
40
+ # @param params [Hash] Input parameters for the flow
41
+ # @param sub_flows [Hash] Additional sub-flows for map nodes
42
+ # @param metadata [Hash] Additional metadata to merge into context
43
+ # @return [Stellwerk::Result]
44
+ def execute(params = {}, sub_flows: {}, metadata: {})
45
+ Evaluator.call(
46
+ compiled_json: @compiled_json,
47
+ params: params,
48
+ sub_flows: sub_flows,
49
+ metadata: metadata
50
+ )
51
+ end
52
+
53
+ # Get the flow version from compiled JSON
54
+ def version
55
+ @compiled_json["version"] || @compiled_json[:version]
56
+ end
57
+
58
+ # Get compilation timestamp
59
+ def compiled_at
60
+ @compiled_json["compiled_at"] || @compiled_json[:compiled_at]
61
+ end
62
+
63
+ # Get entry node IDs
64
+ def entry_node_ids
65
+ @compiled_json["entry_node_ids"] || @compiled_json[:entry_node_ids] || []
66
+ end
67
+
68
+ # Get all node IDs
69
+ def node_ids
70
+ nodes = @compiled_json["nodes"] || @compiled_json[:nodes] || {}
71
+ nodes.keys
72
+ end
73
+
74
+ private
75
+
76
+ def validate!
77
+ nodes = @compiled_json["nodes"] || @compiled_json[:nodes]
78
+ unless nodes.is_a?(Hash)
79
+ raise InvalidFlowError, "Compiled flow must have a 'nodes' hash"
80
+ end
81
+
82
+ entry_ids = @compiled_json["entry_node_ids"] || @compiled_json[:entry_node_ids]
83
+ if nodes.any? && (entry_ids.nil? || entry_ids.empty?)
84
+ Stellwerk.logger.warn("Flow has nodes but no entry_node_ids defined")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dentaku"
4
+
5
+ # Collection & aggregation helper functions for Dentaku.
6
+ # We override common aggregators (SUM, COUNT, MIN, MAX) to be dual-mode:
7
+ # - Classic Dentaku usage: SUM(a, b, c)
8
+ # - Array mode: SUM(array) or SUM(array_of_numbers) or SUM(array_of_mixed)
9
+ # Additional functions: FIRST, LAST, TAKE, PROJECT (internal helper)
10
+ # Projection pattern: identifier[*].field is preprocessed into PROJECT(identifier, "field")
11
+ # Mixed-type coercion:
12
+ # - Numeric values kept
13
+ # - Numeric-looking strings converted to Float
14
+ # - nil ignored
15
+ # - Other types ignored for numeric aggregations
16
+ # - If no numeric values remain -> SUM = 0, MIN/MAX = nil, COUNT counts *all non-nil projected elements* (not just numeric)
17
+ # TAKE(array, n) -> first n elements (n coerced to integer, negative treated as 0)
18
+ # FIRST(array) / LAST(array) -> element or nil
19
+
20
+ module Stellwerk
21
+ module Functions
22
+ module_function
23
+
24
+ def ensure_array(arg)
25
+ return [] if arg.nil?
26
+ return arg if arg.is_a?(Array)
27
+ [arg]
28
+ end
29
+
30
+ def coerce_numeric_list(list)
31
+ list.filter_map do |v|
32
+ case v
33
+ when Numeric then v.to_f
34
+ when String
35
+ v_strip = v.strip
36
+ if v_strip.match?(/^[-+]?\d+(?:\.\d+)?$/)
37
+ v_strip.to_f
38
+ end
39
+ else
40
+ nil
41
+ end
42
+ end
43
+ end
44
+
45
+ # Internal projection helper: given an array of hashes (or objects responding to [] / .public_send)
46
+ # and a field name, extract that field, allowing nils.
47
+ # Named PROJECT (instead of __project) because Dentaku will constantize function names.
48
+ def PROJECT(collection, field_name)
49
+ arr = ensure_array(collection)
50
+ arr.map do |item|
51
+ if item.is_a?(Hash)
52
+ item[field_name.to_sym] || item[field_name.to_s]
53
+ elsif item.respond_to?(:[]) && !item.is_a?(String)
54
+ begin
55
+ item[field_name]
56
+ rescue StandardError
57
+ nil
58
+ end
59
+ elsif item.respond_to?(field_name)
60
+ begin
61
+ item.public_send(field_name)
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ else
66
+ nil
67
+ end
68
+ end
69
+ end
70
+
71
+ # DEEP_PROJECT(root, path_string, field_name)
72
+ # Traverse a dotted path to reach an array, then extract field_name from each element.
73
+ # Example: DEEP_PROJECT(order, "items", "price") where order[:items] is an array of hashes.
74
+ def DEEP_PROJECT(root, path_string, field_name)
75
+ return [] if root.nil?
76
+ current = root
77
+ path_segments = path_string.to_s.split(".")
78
+ path_segments.each do |seg|
79
+ break if current.nil?
80
+ if current.is_a?(Hash)
81
+ current = current[seg.to_sym] || current[seg.to_s]
82
+ elsif current.respond_to?(seg)
83
+ begin
84
+ current = current.public_send(seg)
85
+ rescue StandardError
86
+ current = nil
87
+ end
88
+ else
89
+ current = nil
90
+ end
91
+ end
92
+ arr = ensure_array(current)
93
+ arr.map do |item|
94
+ if item.is_a?(Hash)
95
+ item[field_name.to_sym] || item[field_name.to_s]
96
+ elsif item.respond_to?(:[]) && !item.is_a?(String)
97
+ begin
98
+ item[field_name]
99
+ rescue StandardError
100
+ nil
101
+ end
102
+ elsif item.respond_to?(field_name)
103
+ begin
104
+ item.public_send(field_name)
105
+ rescue StandardError
106
+ nil
107
+ end
108
+ else
109
+ nil
110
+ end
111
+ end
112
+ end
113
+
114
+ # SUM dual mode
115
+ def SUM(*args)
116
+ values = if args.length == 1 && args.first.is_a?(Array)
117
+ coerce_numeric_list(args.first)
118
+ else
119
+ coerce_numeric_list(args)
120
+ end
121
+ values.sum
122
+ end
123
+
124
+ # COUNT dual mode: counts non-nil entries. If single array arg, count its non-nil members.
125
+ def COUNT(*args)
126
+ if args.length == 1 && args.first.is_a?(Array)
127
+ args.first.compact.length
128
+ else
129
+ args.compact.length
130
+ end
131
+ end
132
+
133
+ # MIN dual mode: returns nil if no numeric values
134
+ def MIN(*args)
135
+ values = if args.length == 1 && args.first.is_a?(Array)
136
+ coerce_numeric_list(args.first)
137
+ else
138
+ coerce_numeric_list(args)
139
+ end
140
+ values.min
141
+ end
142
+
143
+ # MAX dual mode: returns nil if no numeric values
144
+ def MAX(*args)
145
+ values = if args.length == 1 && args.first.is_a?(Array)
146
+ coerce_numeric_list(args.first)
147
+ else
148
+ coerce_numeric_list(args)
149
+ end
150
+ values.max
151
+ end
152
+
153
+ def FIRST(arg)
154
+ arr = ensure_array(arg)
155
+ arr.first
156
+ end
157
+
158
+ def LAST(arg)
159
+ arr = ensure_array(arg)
160
+ arr.last
161
+ end
162
+
163
+ def TAKE(arg, n)
164
+ arr = ensure_array(arg)
165
+ limit = begin
166
+ Integer(n)
167
+ rescue StandardError
168
+ 0
169
+ end
170
+ return [] if limit <= 0
171
+ arr.first(limit)
172
+ end
173
+
174
+ # -----------------------------
175
+ # Phase 2 Functions
176
+ # -----------------------------
177
+ # MAP(collection, expr_string, safe: false)
178
+ # - Applies a Dentaku expression to each element with variable 'item' bound to element
179
+ # FILTER(collection, predicate_expr)
180
+ # - Keeps elements where predicate_expr evaluates truthy
181
+ # DISTINCT(collection)
182
+ # - Removes duplicates via Ruby equality
183
+ # AVERAGE/AVG dual-mode like SUM but returns nil if no numeric values
184
+ # SUMIF(collection, predicate_expr, projection_expr=nil)
185
+ # - If projection_expr provided, sum of projection for items where predicate matches
186
+ # - Else sum of numeric-looking items themselves
187
+ # COUNTIF(collection, predicate_expr)
188
+ # - Count of elements where predicate is truthy
189
+ # TRACE(value, label=nil)
190
+ # - Returns value unchanged; placeholder for future instrumentation hook
191
+
192
+ def MAP(collection, expr, calculator: Dentaku::Calculator.new)
193
+ arr = ensure_array(collection)
194
+ return [] if expr.nil? || expr.strip.empty?
195
+ arr.map do |item|
196
+ begin
197
+ # Provide 'item' binding; if item is a Hash, flatten simple keys
198
+ ctx = { "item" => item }
199
+ if item.is_a?(Hash)
200
+ item.each { |k, v| ctx[k.to_s] = v }
201
+ end
202
+ calculator.evaluate!(expr, ctx)
203
+ rescue StandardError
204
+ nil
205
+ end
206
+ end
207
+ end
208
+
209
+ def FILTER(collection, predicate_expr, calculator: Dentaku::Calculator.new)
210
+ arr = ensure_array(collection)
211
+ return arr if predicate_expr.nil? || predicate_expr.strip.empty?
212
+ arr.select do |item|
213
+ begin
214
+ ctx = { "item" => item }
215
+ if item.is_a?(Hash)
216
+ item.each { |k, v| ctx[k.to_s] = v }
217
+ end
218
+ !!calculator.evaluate!(predicate_expr, ctx)
219
+ rescue StandardError
220
+ false
221
+ end
222
+ end
223
+ end
224
+
225
+ def DISTINCT(collection)
226
+ ensure_array(collection).uniq
227
+ end
228
+
229
+ def AVERAGE(*args)
230
+ values = if args.length == 1 && args.first.is_a?(Array)
231
+ coerce_numeric_list(args.first)
232
+ else
233
+ coerce_numeric_list(args)
234
+ end
235
+ return nil if values.empty?
236
+ values.sum / values.length.to_f
237
+ end
238
+ alias AVG AVERAGE
239
+
240
+ def SUMIF(collection, predicate_expr, projection_expr = nil, calculator: Dentaku::Calculator.new)
241
+ arr = ensure_array(collection)
242
+ return 0 if predicate_expr.nil? || predicate_expr.strip.empty?
243
+ total = 0.0
244
+ arr.each do |item|
245
+ begin
246
+ ctx = { "item" => item }
247
+ if item.is_a?(Hash)
248
+ item.each { |k, v| ctx[k.to_s] = v }
249
+ end
250
+ next unless calculator.evaluate!(predicate_expr, ctx)
251
+ value = if projection_expr && !projection_expr.strip.empty?
252
+ calculator.evaluate!(projection_expr, ctx) rescue nil
253
+ else
254
+ item
255
+ end
256
+ case value
257
+ when Numeric
258
+ total += value.to_f
259
+ when String
260
+ v = value.strip
261
+ total += v.to_f if v.match?(/^[-+]?\d+(?:\.\d+)?$/)
262
+ end
263
+ rescue StandardError
264
+ next
265
+ end
266
+ end
267
+ total
268
+ end
269
+
270
+ def COUNTIF(collection, predicate_expr, calculator: Dentaku::Calculator.new)
271
+ arr = ensure_array(collection)
272
+ return 0 if predicate_expr.nil? || predicate_expr.strip.empty?
273
+ count = 0
274
+ arr.each do |item|
275
+ begin
276
+ ctx = { "item" => item }
277
+ if item.is_a?(Hash)
278
+ item.each { |k, v| ctx[k.to_s] = v }
279
+ end
280
+ count += 1 if calculator.evaluate!(predicate_expr, ctx)
281
+ rescue StandardError
282
+ # ignore
283
+ end
284
+ end
285
+ count
286
+ end
287
+
288
+ def REDUCE(collection, initial, expr, calculator: Dentaku::Calculator.new)
289
+ arr = ensure_array(collection)
290
+ return initial if arr.empty?
291
+
292
+ accumulator = initial
293
+ arr.each do |item|
294
+ begin
295
+ ctx = { "item" => item, "accumulator" => accumulator, "acc" => accumulator }
296
+ if item.is_a?(Hash)
297
+ item.each { |k, v| ctx[k.to_s] = v }
298
+ end
299
+ accumulator = calculator.evaluate!(expr, ctx)
300
+ rescue StandardError
301
+ nil
302
+ end
303
+ end
304
+ accumulator
305
+ end
306
+
307
+ def TRACE(value, _label = nil)
308
+ # Future: push to instrumentation buffer
309
+ value
310
+ end
311
+
312
+ # ARRAY function - creates an array from arguments
313
+ # Allows: values = ARRAY(1, 2, 3) which can then be used with SUM(values)
314
+ def ARRAY(*args)
315
+ args
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stellwerk
4
+ # Represents the result of a flow execution
5
+ class Result
6
+ attr_reader :outputs, :errors, :applied_nodes, :context
7
+
8
+ def initialize(outputs:, errors:, applied_nodes:, context:)
9
+ @outputs = outputs || {}
10
+ @errors = errors || []
11
+ @applied_nodes = applied_nodes || []
12
+ @context = context || {}
13
+ end
14
+
15
+ # Returns true if the flow executed without errors
16
+ def success?
17
+ errors.empty?
18
+ end
19
+
20
+ # Returns true if there were any errors during execution
21
+ def failure?
22
+ !success?
23
+ end
24
+
25
+ # Convert to hash representation
26
+ def to_h
27
+ {
28
+ outputs: outputs,
29
+ errors: errors,
30
+ applied_nodes: applied_nodes,
31
+ context: context
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stellwerk
4
+ VERSION = "0.1.0"
5
+ end
data/lib/stellwerk.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "stellwerk/version"
6
+ require_relative "stellwerk/errors"
7
+ require_relative "stellwerk/result"
8
+ require_relative "stellwerk/functions"
9
+ require_relative "stellwerk/evaluator"
10
+ require_relative "stellwerk/flow"
11
+
12
+ # Stellwerk - A standalone Ruby gem for evaluating pre-compiled flow JSON
13
+ #
14
+ # This gem provides a Rails-free implementation for executing flows that have
15
+ # been compiled by the Stellwerk platform. It works with the compiled JSON
16
+ # format that includes all node definitions, adjacency lists, and embedded
17
+ # sub-flows.
18
+ #
19
+ # @example Simple usage
20
+ # flow = Stellwerk::Flow.load('pricing.compiled.json')
21
+ # result = flow.execute(quantity: 10, price: 25.00)
22
+ #
23
+ # if result.success?
24
+ # puts result.outputs[:total]
25
+ # else
26
+ # puts result.errors
27
+ # end
28
+ #
29
+ # @example Direct evaluator usage
30
+ # json = JSON.parse(File.read('flow.json'))
31
+ # result = Stellwerk::Evaluator.call(compiled_json: json, params: { x: 10 })
32
+ #
33
+ # @example Configuration
34
+ # Stellwerk.configure do |config|
35
+ # config.logger = Logger.new(STDOUT)
36
+ # end
37
+ #
38
+ module Stellwerk
39
+ class << self
40
+ # Configuration options
41
+ attr_writer :logger
42
+
43
+ # Returns the configured logger (defaults to a null logger)
44
+ def logger
45
+ @logger ||= Logger.new(File::NULL)
46
+ end
47
+
48
+ # Configure Stellwerk
49
+ #
50
+ # @yield [self] Yields self for configuration
51
+ def configure
52
+ yield self
53
+ end
54
+
55
+ # Reset configuration to defaults
56
+ def reset!
57
+ @logger = nil
58
+ end
59
+ end
60
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stellwerk-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Roadwerk
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-01-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dentaku
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.12'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.12'
69
+ description: |
70
+ Stellwerk is a standalone Ruby gem for evaluating pre-compiled flow JSON
71
+ without any Rails or ActiveRecord dependencies. It provides a fast, portable
72
+ way to execute decision logic and calculations defined in the Stellwerk platform.
73
+ email:
74
+ - team@roadwerk.io
75
+ executables: []
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - CHANGELOG.md
80
+ - LICENSE.txt
81
+ - README.md
82
+ - lib/stellwerk.rb
83
+ - lib/stellwerk/errors.rb
84
+ - lib/stellwerk/evaluator.rb
85
+ - lib/stellwerk/flow.rb
86
+ - lib/stellwerk/functions.rb
87
+ - lib/stellwerk/result.rb
88
+ - lib/stellwerk/version.rb
89
+ homepage: https://github.com/roadwerk/stellwerk-ruby
90
+ licenses:
91
+ - MIT
92
+ metadata:
93
+ homepage_uri: https://github.com/roadwerk/stellwerk-ruby
94
+ source_code_uri: https://github.com/roadwerk/stellwerk-ruby
95
+ changelog_uri: https://github.com/roadwerk/stellwerk-ruby/blob/main/CHANGELOG.md
96
+ post_install_message:
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: 3.0.0
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.4.10
112
+ signing_key:
113
+ specification_version: 4
114
+ summary: Evaluate pre-compiled Stellwerk flows in pure Ruby
115
+ test_files: []