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,748 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "dentaku"
5
+ require "yaml"
6
+ require "json"
7
+
8
+ module Stellwerk
9
+ # Evaluates flows using pre-compiled JSON representation for maximum performance.
10
+ # Avoids all database queries by working directly with the compiled JSON structure.
11
+ #
12
+ # The compiled JSON format:
13
+ # {
14
+ # "version": "1.0",
15
+ # "compiled_at": "...",
16
+ # "entry_node_ids": [...],
17
+ # "nodes": { "node_id": { id, name, type, config, ... } },
18
+ # "adjacency": { "node_id": [{ "to": "...", "branch": "..." }] },
19
+ # "reverse_adjacency": { "node_id": ["parent_id", ...] },
20
+ # "sub_flows": { "flow_id": { ... embedded sub-flow ... } }
21
+ # }
22
+ class Evaluator
23
+ # --------------------------------------------------
24
+ # Public API
25
+ # --------------------------------------------------
26
+ def self.call(compiled_json:, params:, sub_flows: {}, metadata: {})
27
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ result = new(compiled_json: compiled_json, params: params, sub_flows: sub_flows, metadata: metadata).evaluate
29
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
30
+
31
+ # Track metrics for observability if Metrics is available
32
+ if defined?(Metrics)
33
+ status = result.errors.any? ? "error" : "success"
34
+ Metrics.increment("stellwerk.flow.executions", tags: { status: status })
35
+ Metrics.timing("stellwerk.flow.execution_time", duration_ms)
36
+ Metrics.histogram("stellwerk.flow.nodes_evaluated", result.applied_nodes.size)
37
+ end
38
+
39
+ result
40
+ end
41
+
42
+ # --------------------------------------------------
43
+ # Implementation
44
+ # --------------------------------------------------
45
+ def initialize(compiled_json:, params:, sub_flows: {}, metadata: {})
46
+ @compiled = deep_symbolize_keys(compiled_json)
47
+ @nodes = (@compiled[:nodes] || {}).transform_keys(&:to_s)
48
+ @adjacency = @compiled[:adjacency] || {}
49
+ @reverse_adjacency = @compiled[:reverse_adjacency] || {}
50
+ @entry_node_ids = @compiled[:entry_node_ids] || []
51
+ @embedded_sub_flows = @compiled[:sub_flows] || {}
52
+ @external_sub_flows = sub_flows
53
+
54
+ @context = seed_initial_context(metadata, params)
55
+ @calculator = Dentaku::Calculator.new
56
+ register_collection_functions!
57
+ @applied_nodes = []
58
+ @errors = []
59
+ @merge_buffers = Hash.new { |h, k| h[k] = [] }
60
+ @outputs = nil
61
+ end
62
+
63
+ def evaluate
64
+ return build_result if @nodes.empty?
65
+
66
+ if @entry_node_ids.empty?
67
+ @errors << "No start nodes found in flow"
68
+ return build_result
69
+ end
70
+
71
+ evaluate_graph
72
+ end
73
+
74
+ private
75
+
76
+ def deep_symbolize_keys(obj)
77
+ case obj
78
+ when Hash
79
+ obj.each_with_object({}) do |(key, value), result|
80
+ result[key.to_sym] = deep_symbolize_keys(value)
81
+ end
82
+ when Array
83
+ obj.map { |item| deep_symbolize_keys(item) }
84
+ else
85
+ obj
86
+ end
87
+ end
88
+
89
+ def seed_initial_context(metadata, params)
90
+ ctx = {}
91
+
92
+ # Flow metadata (medium priority)
93
+ if metadata && !metadata.empty?
94
+ evaluation_metadata = metadata.reject { |k, _| k.to_s == "sticky_notes" }
95
+ ctx.merge!(deep_symbolize_keys(evaluation_metadata))
96
+ end
97
+
98
+ # Runtime params (highest priority)
99
+ ctx.merge!(deep_symbolize_keys(params.to_h))
100
+
101
+ ctx
102
+ end
103
+
104
+ def evaluate_graph
105
+ # Use pre-computed adjacency lists
106
+ children_by = @adjacency.transform_keys(&:to_s)
107
+ parents_by = @reverse_adjacency.transform_keys(&:to_s)
108
+
109
+ # Token-based traversal
110
+ token_struct = Struct.new(:node_id, :context, :branch_taken)
111
+ queue = []
112
+
113
+ # Seed queue with entry nodes
114
+ @entry_node_ids.each do |node_id|
115
+ queue << token_struct.new(node_id, @context.dup, nil)
116
+ end
117
+
118
+ visited = Set.new
119
+ final_context = @context.dup
120
+
121
+ while (token = queue.shift)
122
+ node = @nodes[token.node_id]
123
+ next unless node
124
+
125
+ node_type = node[:type] || node["type"]
126
+ node_id = node[:id] || node["id"]
127
+
128
+ # Handle merge nodes specially
129
+ if node_type == "merge"
130
+ @merge_buffers[node_id] << { context: token.context, branch: token.branch_taken }
131
+
132
+ expected_count = (parents_by[node_id] || []).size
133
+ if @merge_buffers[node_id].size >= expected_count
134
+ result = execute_merge_node_with_tokens(node, @merge_buffers[node_id])
135
+ @merge_buffers.delete(node_id)
136
+
137
+ if result[:error]
138
+ @errors << { node_id: node_id, error: result[:error] }
139
+ next
140
+ end
141
+
142
+ @applied_nodes << { id: node_id, name: node[:name] || node["name"], type: node_type }
143
+ visited << node_id
144
+
145
+ updated_context = result[:context]
146
+ final_context.merge!(updated_context)
147
+
148
+ (children_by[node_id] || []).each do |child|
149
+ child_data = child.is_a?(Hash) ? child : deep_symbolize_keys(child)
150
+ next if (child_data[:branch] || child_data["branch"]).to_s != ""
151
+ queue << token_struct.new(child_data[:to] || child_data["to"], updated_context.dup, nil)
152
+ end
153
+ end
154
+ next
155
+ end
156
+
157
+ # Execute node
158
+ result = execute_node(node, token.context)
159
+
160
+ next if result[:skip]
161
+
162
+ if result[:error]
163
+ @errors << { node_id: node_id, error: result[:error] }
164
+ next
165
+ end
166
+
167
+ @applied_nodes << { id: node_id, name: node[:name] || node["name"], type: node_type }
168
+ visited << node_id
169
+
170
+ updated_context = result[:context] || token.context
171
+ final_context.merge!(updated_context)
172
+
173
+ # Handle branching
174
+ if result[:branch]
175
+ children = (children_by[node_id] || []).select do |c|
176
+ c_data = c.is_a?(Hash) ? c : deep_symbolize_keys(c)
177
+ (c_data[:branch] || c_data["branch"]) == result[:branch]
178
+ end
179
+ children.each do |child|
180
+ child_data = child.is_a?(Hash) ? child : deep_symbolize_keys(child)
181
+ queue << token_struct.new(child_data[:to] || child_data["to"], updated_context.dup, result[:branch])
182
+ end
183
+ else
184
+ (children_by[node_id] || []).each do |child|
185
+ child_data = child.is_a?(Hash) ? child : deep_symbolize_keys(child)
186
+ next if (child_data[:branch] || child_data["branch"]).to_s != ""
187
+ queue << token_struct.new(child_data[:to] || child_data["to"], updated_context.dup, nil)
188
+ end
189
+ end
190
+ end
191
+
192
+ @context = final_context
193
+ build_result
194
+ end
195
+
196
+ def execute_node(node, context)
197
+ node_type = node[:type] || node["type"]
198
+
199
+ case node_type
200
+ when "start" then execute_start_node(node, context)
201
+ when "calculate" then execute_calculate_node(node, context)
202
+ when "condition" then execute_condition_node(node, context)
203
+ when "merge" then execute_merge_node(node, context)
204
+ when "end" then execute_end_node(node, context)
205
+ when "map" then execute_map_node(node, context)
206
+ when "switch" then execute_switch_node(node, context)
207
+ else
208
+ { error: "Unknown node type: #{node_type}" }
209
+ end
210
+ rescue StandardError => e
211
+ Stellwerk.logger.error("Error executing compiled node #{node[:id]}: #{e.message}")
212
+ { error: e.message }
213
+ end
214
+
215
+ # --------------------------------------------------
216
+ # Node Executors
217
+ # --------------------------------------------------
218
+
219
+ def execute_start_node(node, context)
220
+ input_schema = node[:inputs] || node["inputs"] || node.dig(:config, "inputs") || []
221
+
222
+ updated_context = context.dup
223
+
224
+ input_schema.each do |input_def|
225
+ input_def = input_def.transform_keys(&:to_s) if input_def.is_a?(Hash)
226
+ name = input_def["name"]
227
+ sym_name = name.to_sym
228
+
229
+ if !updated_context.key?(sym_name) && input_def.key?("default")
230
+ updated_context[sym_name] = input_def["default"]
231
+ end
232
+
233
+ if input_def["required"] && !updated_context.key?(sym_name)
234
+ return { error: "Required input missing: #{name}" }
235
+ end
236
+ end
237
+
238
+ { context: updated_context }
239
+ end
240
+
241
+ def execute_calculate_node(node, context)
242
+ formula = node[:formula] || node["formula"] || node.dig(:config, "formula")
243
+ return { context: context } if formula.nil? || formula.to_s.strip.empty?
244
+
245
+ processed_formula = preprocess_collections(normalize_expression(formula))
246
+ flat_context = flatten_context(context)
247
+
248
+ begin
249
+ result_context = apply_formula(processed_formula, flat_context)
250
+
251
+ outputs = {}
252
+ declared = node[:declared_outputs] || node["declared_outputs"] || []
253
+ declared.each do |var_name|
254
+ outputs[var_name.to_sym] = result_context[var_name.to_sym] if result_context.key?(var_name.to_sym)
255
+ end
256
+
257
+ { context: context.merge(outputs) }
258
+ rescue StandardError => e
259
+ Stellwerk.logger.warn("Calculate node formula error: #{e.message}")
260
+ { error: e.message }
261
+ end
262
+ end
263
+
264
+ def execute_condition_node(node, context)
265
+ condition = node[:condition] || node["condition"] || node.dig(:config, "condition")
266
+ return { skip: true } if condition.nil? || condition.to_s.strip.empty?
267
+
268
+ processed_condition = preprocess_collections(normalize_expression(condition))
269
+ flat_context = flatten_context(context)
270
+
271
+ begin
272
+ result = @calculator.evaluate!(processed_condition, flat_context)
273
+ branch = result ? "true" : "false"
274
+ { context: context, branch: branch }
275
+ rescue StandardError => e
276
+ Stellwerk.logger.warn("Condition node error: #{e.message}")
277
+ { error: e.message }
278
+ end
279
+ end
280
+
281
+ def execute_switch_node(node, context)
282
+ cases = node[:cases] || node["cases"] || node.dig(:config, "cases") || []
283
+
284
+ flat_context = flatten_context(context)
285
+ match_id = nil
286
+
287
+ cases.each do |case_def|
288
+ case_def = case_def.transform_keys(&:to_s) if case_def.is_a?(Hash)
289
+ condition = case_def["condition"]
290
+ next if condition.nil? || condition.to_s.strip.empty?
291
+
292
+ begin
293
+ processed_condition = preprocess_collections(normalize_expression(condition))
294
+ is_match = @calculator.evaluate!(processed_condition, flat_context)
295
+
296
+ if is_match
297
+ match_id = case_def["id"]
298
+ break
299
+ end
300
+ rescue Dentaku::UnboundVariableError => e
301
+ return { error: "Switch case '#{case_def['label']}' error: #{e.message}" }
302
+ rescue StandardError => e
303
+ Stellwerk.logger.warn("Switch node case error: #{e.message}")
304
+ return { error: "Switch case '#{case_def['label']}' error: #{e.message}" }
305
+ end
306
+ end
307
+
308
+ branch = match_id || "default"
309
+ { context: context, branch: branch }
310
+ end
311
+
312
+ def execute_merge_node(node, context)
313
+ { context: context }
314
+ end
315
+
316
+ def execute_merge_node_with_tokens(node, tokens)
317
+ strategy = node[:strategy] || node["strategy"] || node.dig(:config, "strategy") || "wait_for_all"
318
+
319
+ case strategy
320
+ when "wait_for_all", "merge_all"
321
+ merged_context = {}
322
+ tokens.each { |t| merged_context.merge!(t[:context]) }
323
+ { context: merged_context }
324
+
325
+ when "first_arrival"
326
+ { context: tokens.first[:context] }
327
+
328
+ when "pick_highest", "pick_lowest"
329
+ selection_expr = node[:join_selection_expr] || node["join_selection_expr"] || node.dig(:config, "join_selection_expr")
330
+
331
+ if selection_expr.nil? || selection_expr.to_s.strip.empty?
332
+ return { error: "Merge strategy '#{strategy}' requires join_selection_expr" }
333
+ end
334
+
335
+ scored_tokens = tokens.map do |token_data|
336
+ begin
337
+ processed_expr = preprocess_collections(normalize_expression(selection_expr))
338
+ flat_context = flatten_context(token_data[:context])
339
+ score = @calculator.evaluate!(processed_expr, flat_context)
340
+ { context: token_data[:context], score: score }
341
+ rescue StandardError => e
342
+ Stellwerk.logger.warn("Merge selection error: #{e.message}")
343
+ { context: token_data[:context], score: nil }
344
+ end
345
+ end
346
+
347
+ valid_tokens = scored_tokens.reject { |t| t[:score].nil? }
348
+
349
+ if valid_tokens.empty?
350
+ return { error: "No valid scores for merge selection" }
351
+ end
352
+
353
+ winner = strategy == "pick_highest" ? valid_tokens.max_by { |t| t[:score] } : valid_tokens.min_by { |t| t[:score] }
354
+ { context: winner[:context] }
355
+
356
+ else
357
+ merged_context = {}
358
+ tokens.each { |t| merged_context.merge!(t[:context]) }
359
+ { context: merged_context }
360
+ end
361
+ end
362
+
363
+ def execute_end_node(node, context)
364
+ output_template = node[:output_template] || node["output_template"] || node.dig(:config, "output_template")
365
+ yaml_template = node[:yaml_template] || node["yaml_template"] || node.dig(:config, "yaml_template")
366
+ output_mapping = node[:output_mapping] || node["output_mapping"] || node.dig(:config, "output_mapping") || []
367
+
368
+ if output_template && !output_template.to_s.strip.empty?
369
+ @outputs = process_json_template(output_template, context)
370
+ elsif yaml_template && !yaml_template.to_s.strip.empty?
371
+ @outputs = process_yaml_template(yaml_template, context)
372
+ else
373
+ @outputs = {}
374
+ end
375
+
376
+ outputs_empty = @outputs.nil? || (@outputs.is_a?(Hash) && @outputs.empty?)
377
+ if outputs_empty && !output_mapping.empty?
378
+ mapped_outputs = {}
379
+ output_mapping.each do |mapping|
380
+ mapping = mapping.transform_keys(&:to_s) if mapping.is_a?(Hash)
381
+ source = mapping["from"] || mapping["name"]
382
+ target = mapping["name"]
383
+ mapped_outputs[target.to_sym] = context[source.to_sym] if context.key?(source.to_sym)
384
+ end
385
+ @outputs = mapped_outputs
386
+ end
387
+
388
+ { context: context }
389
+ end
390
+
391
+ def execute_map_node(node, context)
392
+ source_var = node[:source_variable] || node["source_variable"] || node.dig(:config, "source_variable")
393
+ item_alias = node[:item_alias] || node["item_alias"] || node.dig(:config, "item_alias") || "item"
394
+ index_alias = node[:index_alias] || node["index_alias"] || node.dig(:config, "index_alias")
395
+ output_var = node[:output_variable] || node["output_variable"] || node.dig(:config, "output_variable") || "results"
396
+ sub_flow_id = node[:sub_flow_id] || node["sub_flow_id"] || node.dig(:config, "sub_flow_id")
397
+ enrich_source = node[:enrich_source_array] || node["enrich_source_array"] || node.dig(:config, "enrich_source_array") == true
398
+
399
+ source_array = context[source_var.to_sym]
400
+ unless source_array.is_a?(Array)
401
+ return { error: "MapNode source '#{source_var}' is not an array" }
402
+ end
403
+
404
+ if sub_flow_id && sub_flow_id.to_s.strip != ""
405
+ # Look up sub-flow from embedded sub_flows or external sub_flows
406
+ sub_flow_json = @embedded_sub_flows[sub_flow_id.to_s] ||
407
+ @embedded_sub_flows[sub_flow_id.to_sym] ||
408
+ @external_sub_flows[sub_flow_id.to_s] ||
409
+ @external_sub_flows[sub_flow_id.to_sym]
410
+
411
+ unless sub_flow_json
412
+ return { error: "MapNode referenced sub-flow #{sub_flow_id} not found. Embed sub-flows in compiled JSON or pass via sub_flows parameter." }
413
+ end
414
+
415
+ results = []
416
+ source_array.each_with_index do |item, index|
417
+ item_context = item.is_a?(Hash) ? deep_symbolize_keys(item) : { item_alias.to_sym => item }
418
+ item_context[index_alias.to_sym] = index if index_alias && index_alias.to_s.strip != ""
419
+
420
+ # Recursively evaluate sub-flow
421
+ subflow_result = Evaluator.call(
422
+ compiled_json: sub_flow_json,
423
+ params: item_context,
424
+ sub_flows: @external_sub_flows.merge(@embedded_sub_flows)
425
+ )
426
+
427
+ if subflow_result.errors.any?
428
+ results << { error: subflow_result.errors.map { |e| e.is_a?(Hash) ? e[:error] : e.to_s }.join("; ") }
429
+ else
430
+ if enrich_source
431
+ results << deep_merge_hashes(item, subflow_result.outputs)
432
+ else
433
+ results << subflow_result.outputs
434
+ end
435
+ end
436
+ end
437
+
438
+ output_context = context.dup
439
+ output_context[output_var.to_sym] = results
440
+ { context: output_context }
441
+ else
442
+ { error: "MapNode requires a sub-flow" }
443
+ end
444
+ end
445
+
446
+ # --------------------------------------------------
447
+ # Helper Methods
448
+ # --------------------------------------------------
449
+
450
+ def apply_formula(formula, context)
451
+ statements = join_multiline_statements(formula)
452
+ temp_ctx = context.dup
453
+
454
+ statements.each do |statement|
455
+ next if statement.start_with?("#")
456
+ break if statement.match?(/^\s*(export|return)\b/i)
457
+
458
+ if statement =~ /^(\w+)\s*:?=\s*(.+)$/m
459
+ var_name = $1.to_sym
460
+ expr = $2
461
+ processed_expr = preprocess_collections(expr)
462
+
463
+ begin
464
+ value = @calculator.evaluate!(processed_expr, temp_ctx)
465
+ # Convert BigDecimal to Float for proper JSON serialization
466
+ value = value.to_f if value.is_a?(BigDecimal)
467
+ temp_ctx[var_name] = value
468
+ rescue StandardError => e
469
+ Stellwerk.logger.warn("Formula statement error: #{e.message}")
470
+ raise e
471
+ end
472
+ end
473
+ end
474
+
475
+ temp_ctx
476
+ end
477
+
478
+ def join_multiline_statements(formula)
479
+ lines = formula.to_s.gsub("\r", "\n").split("\n").map(&:strip)
480
+ statements = []
481
+ current_statement = ""
482
+ paren_depth = 0
483
+
484
+ lines.each do |line|
485
+ next if line.empty?
486
+
487
+ if line.start_with?("#") && paren_depth == 0
488
+ statements << line
489
+ next
490
+ end
491
+
492
+ line_without_comment = line.gsub(/#.*$/, "").strip
493
+
494
+ if current_statement.empty?
495
+ current_statement = line_without_comment
496
+ else
497
+ current_statement += " " + line_without_comment
498
+ end
499
+
500
+ paren_depth += line_without_comment.count("(") - line_without_comment.count(")")
501
+
502
+ if paren_depth <= 0
503
+ statements << current_statement unless current_statement.empty?
504
+ current_statement = ""
505
+ paren_depth = 0
506
+ end
507
+ end
508
+
509
+ statements << current_statement unless current_statement.empty?
510
+ statements
511
+ end
512
+
513
+ def normalize_expression(expr)
514
+ expr.to_s.gsub("==", "=")
515
+ end
516
+
517
+ def preprocess_collections(expr_str)
518
+ result = expr_str.dup
519
+
520
+ result = result.gsub(/\[([^\[\]*]+)\]/) do |match|
521
+ inner = $1
522
+ inner.strip == "*" ? match : "ARRAY(#{inner})"
523
+ end
524
+
525
+ result.gsub(/(\w+)(\[\*\])+(\.\w+)+/) do
526
+ root = $1
527
+ path_str = $3
528
+ fields = path_str.scan(/\.\w+/).map { |f| f[1..-1] }
529
+
530
+ if fields.size == 1
531
+ "project(#{root}, \"#{fields.first}\")"
532
+ else
533
+ "deep_project(#{root}, #{fields.map { |f| "\"#{f}\"" }.join(', ')})"
534
+ end
535
+ end
536
+ end
537
+
538
+ def flatten_context(ctx)
539
+ result = {}
540
+ ctx.each do |key, value|
541
+ if value.is_a?(Hash)
542
+ value.each do |nested_key, nested_value|
543
+ result["#{key}.#{nested_key}".to_sym] = nested_value
544
+ end
545
+ else
546
+ result[key] = value
547
+ end
548
+ end
549
+ result
550
+ end
551
+
552
+ def deep_merge_hashes(base, overlay)
553
+ return overlay unless base.is_a?(Hash)
554
+ return base unless overlay.is_a?(Hash)
555
+
556
+ base_sym = deep_symbolize_keys(base)
557
+ overlay_sym = deep_symbolize_keys(overlay)
558
+
559
+ base_sym.merge(overlay_sym) do |_key, old_val, new_val|
560
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
561
+ deep_merge_hashes(old_val, new_val)
562
+ else
563
+ new_val
564
+ end
565
+ end
566
+ end
567
+
568
+ # --------------------------------------------------
569
+ # Template Processing (from EndNode)
570
+ # --------------------------------------------------
571
+
572
+ def process_json_template(template, context)
573
+ interpolate_value(template, context)
574
+ end
575
+
576
+ def process_yaml_template(yaml_template, context)
577
+ return {} if yaml_template.nil? || yaml_template.to_s.strip.empty?
578
+
579
+ single_placeholder_match = yaml_template.strip.match(/\A\{\{\s*([\w.]+)\s*\}\}\z/)
580
+ if single_placeholder_match
581
+ var_path = single_placeholder_match[1]
582
+ return resolve_path(var_path, context)
583
+ end
584
+
585
+ begin
586
+ template = YAML.safe_load(yaml_template)
587
+ interpolate_value(template, context)
588
+ rescue Psych::SyntaxError
589
+ {}
590
+ end
591
+ end
592
+
593
+ def interpolate_value(value, context)
594
+ case value
595
+ when Hash
596
+ value.transform_values { |v| interpolate_value(v, context) }
597
+ when Array
598
+ value.map { |v| interpolate_value(v, context) }
599
+ when String
600
+ interpolate_string(value, context)
601
+ else
602
+ value
603
+ end
604
+ end
605
+
606
+ def interpolate_string(value, context)
607
+ jsonpath_single_match = value.match(/\A\s*\$\.([\w.]+)\s*\z/)
608
+ mustache_single_match = value.match(/\A\s*\{\{\s*([\w.]+)\s*\}\}\s*\z/)
609
+
610
+ if jsonpath_single_match
611
+ resolve_path(jsonpath_single_match[1], context)
612
+ elsif mustache_single_match
613
+ resolve_path(mustache_single_match[1], context)
614
+ else
615
+ result = value.dup
616
+ result = result.gsub(/\$\.([\w.]+)/) do |match|
617
+ var_path = Regexp.last_match(1)
618
+ val = resolve_path(var_path, context)
619
+ format_interpolated_value(val, match)
620
+ end
621
+ result = result.gsub(/\{\{\s*([\w.]+)\s*\}\}/) do |match|
622
+ var_path = Regexp.last_match(1)
623
+ val = resolve_path(var_path, context)
624
+ format_interpolated_value(val, match)
625
+ end
626
+ result
627
+ end
628
+ end
629
+
630
+ def format_interpolated_value(val, original_match)
631
+ if val.nil?
632
+ original_match
633
+ elsif val.is_a?(Hash) || val.is_a?(Array)
634
+ val.to_json
635
+ else
636
+ val.to_s
637
+ end
638
+ end
639
+
640
+ def resolve_path(path, context)
641
+ parts = path.split(".")
642
+ current = context
643
+
644
+ parts.each do |part|
645
+ return nil if current.nil?
646
+
647
+ current = if current.is_a?(Hash)
648
+ current[part.to_sym] || current[part]
649
+ elsif current.respond_to?(part)
650
+ current.public_send(part)
651
+ else
652
+ nil
653
+ end
654
+ end
655
+
656
+ current
657
+ end
658
+
659
+ # --------------------------------------------------
660
+ # Collection Functions Registration
661
+ # --------------------------------------------------
662
+
663
+ def register_collection_functions!
664
+ @calculator.add_function(:ARRAY, :numeric, ->(*args) { args })
665
+
666
+ @calculator.add_function(:PROJECT, :numeric, ->(collection, field_name) {
667
+ Stellwerk::Functions.PROJECT(collection, field_name)
668
+ })
669
+
670
+ @calculator.add_function(:DEEP_PROJECT, :numeric, ->(root, path_string, field_name) {
671
+ Stellwerk::Functions.DEEP_PROJECT(root, path_string, field_name)
672
+ })
673
+
674
+ @calculator.add_function(:SUM, :numeric, ->(*args) {
675
+ Stellwerk::Functions.SUM(*args)
676
+ })
677
+
678
+ @calculator.add_function(:COUNT, :numeric, ->(*args) {
679
+ Stellwerk::Functions.COUNT(*args)
680
+ })
681
+
682
+ @calculator.add_function(:MIN, :numeric, ->(*args) {
683
+ Stellwerk::Functions.MIN(*args)
684
+ })
685
+
686
+ @calculator.add_function(:MAX, :numeric, ->(*args) {
687
+ Stellwerk::Functions.MAX(*args)
688
+ })
689
+
690
+ @calculator.add_function(:AVERAGE, :numeric, ->(*args) {
691
+ Stellwerk::Functions.AVERAGE(*args)
692
+ })
693
+
694
+ @calculator.add_function(:AVG, :numeric, ->(*args) {
695
+ Stellwerk::Functions.AVERAGE(*args)
696
+ })
697
+
698
+ @calculator.add_function(:FIRST, :numeric, ->(arr) {
699
+ Stellwerk::Functions.FIRST(arr)
700
+ })
701
+
702
+ @calculator.add_function(:LAST, :numeric, ->(arr) {
703
+ Stellwerk::Functions.LAST(arr)
704
+ })
705
+
706
+ @calculator.add_function(:TAKE, :numeric, ->(arr, n) {
707
+ Stellwerk::Functions.TAKE(arr, n)
708
+ })
709
+
710
+ @calculator.add_function(:DISTINCT, :numeric, ->(arr) {
711
+ Stellwerk::Functions.DISTINCT(arr)
712
+ })
713
+
714
+ @calculator.add_function(:MAP, :numeric, ->(collection, expr) {
715
+ Stellwerk::Functions.MAP(collection, expr, calculator: @calculator)
716
+ })
717
+
718
+ @calculator.add_function(:FILTER, :numeric, ->(collection, predicate_expr) {
719
+ Stellwerk::Functions.FILTER(collection, predicate_expr, calculator: @calculator)
720
+ })
721
+
722
+ @calculator.add_function(:REDUCE, :numeric, ->(collection, initial, expr) {
723
+ Stellwerk::Functions.REDUCE(collection, initial, expr, calculator: @calculator)
724
+ })
725
+
726
+ @calculator.add_function(:SUMIF, :numeric, ->(collection, predicate_expr, projection_expr = nil) {
727
+ Stellwerk::Functions.SUMIF(collection, predicate_expr, projection_expr, calculator: @calculator)
728
+ })
729
+
730
+ @calculator.add_function(:COUNTIF, :numeric, ->(collection, predicate_expr) {
731
+ Stellwerk::Functions.COUNTIF(collection, predicate_expr, calculator: @calculator)
732
+ })
733
+
734
+ @calculator.add_function(:TRACE, :numeric, ->(value, label = nil) {
735
+ Stellwerk::Functions.TRACE(value, label)
736
+ })
737
+ end
738
+
739
+ def build_result
740
+ Result.new(
741
+ applied_nodes: @applied_nodes,
742
+ context: @context,
743
+ outputs: @outputs || {},
744
+ errors: @errors
745
+ )
746
+ end
747
+ end
748
+ end