decision_agent 0.2.0 → 1.0.1

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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +313 -8
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/agent.rb +72 -1
  5. data/lib/decision_agent/context.rb +1 -0
  6. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
  7. data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
  8. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
  9. data/lib/decision_agent/data_enrichment/client.rb +220 -0
  10. data/lib/decision_agent/data_enrichment/config.rb +78 -0
  11. data/lib/decision_agent/data_enrichment/errors.rb +36 -0
  12. data/lib/decision_agent/decision.rb +102 -2
  13. data/lib/decision_agent/dmn/adapter.rb +135 -0
  14. data/lib/decision_agent/dmn/cache.rb +306 -0
  15. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  16. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  17. data/lib/decision_agent/dmn/errors.rb +30 -0
  18. data/lib/decision_agent/dmn/exporter.rb +217 -0
  19. data/lib/decision_agent/dmn/feel/evaluator.rb +819 -0
  20. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  21. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  22. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  23. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  24. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  25. data/lib/decision_agent/dmn/importer.rb +77 -0
  26. data/lib/decision_agent/dmn/model.rb +197 -0
  27. data/lib/decision_agent/dmn/parser.rb +191 -0
  28. data/lib/decision_agent/dmn/testing.rb +333 -0
  29. data/lib/decision_agent/dmn/validator.rb +315 -0
  30. data/lib/decision_agent/dmn/versioning.rb +229 -0
  31. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  32. data/lib/decision_agent/dsl/condition_evaluator.rb +984 -838
  33. data/lib/decision_agent/dsl/schema_validator.rb +53 -14
  34. data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
  35. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
  36. data/lib/decision_agent/explainability/condition_trace.rb +83 -0
  37. data/lib/decision_agent/explainability/explainability_result.rb +52 -0
  38. data/lib/decision_agent/explainability/rule_trace.rb +39 -0
  39. data/lib/decision_agent/explainability/trace_collector.rb +24 -0
  40. data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
  41. data/lib/decision_agent/simulation/errors.rb +18 -0
  42. data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
  43. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
  44. data/lib/decision_agent/simulation/replay_engine.rb +486 -0
  45. data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
  46. data/lib/decision_agent/simulation/scenario_library.rb +163 -0
  47. data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
  48. data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
  49. data/lib/decision_agent/simulation.rb +17 -0
  50. data/lib/decision_agent/version.rb +1 -1
  51. data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
  52. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  53. data/lib/decision_agent/web/public/app.js +119 -0
  54. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  55. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  56. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  57. data/lib/decision_agent/web/public/index.html +52 -0
  58. data/lib/decision_agent/web/public/simulation.html +130 -0
  59. data/lib/decision_agent/web/public/simulation_impact.html +478 -0
  60. data/lib/decision_agent/web/public/simulation_replay.html +551 -0
  61. data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
  62. data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
  63. data/lib/decision_agent/web/public/styles.css +86 -0
  64. data/lib/decision_agent/web/server.rb +1059 -23
  65. data/lib/decision_agent.rb +60 -2
  66. metadata +105 -61
  67. data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
  68. data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
  69. data/spec/ab_testing/ab_test_spec.rb +0 -270
  70. data/spec/ab_testing/ab_testing_agent_spec.rb +0 -481
  71. data/spec/ab_testing/storage/adapter_spec.rb +0 -64
  72. data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
  73. data/spec/activerecord_thread_safety_spec.rb +0 -553
  74. data/spec/advanced_operators_spec.rb +0 -3150
  75. data/spec/agent_spec.rb +0 -289
  76. data/spec/api_contract_spec.rb +0 -430
  77. data/spec/audit_adapters_spec.rb +0 -92
  78. data/spec/auth/access_audit_logger_spec.rb +0 -394
  79. data/spec/auth/authenticator_spec.rb +0 -112
  80. data/spec/auth/password_reset_spec.rb +0 -294
  81. data/spec/auth/permission_checker_spec.rb +0 -207
  82. data/spec/auth/permission_spec.rb +0 -73
  83. data/spec/auth/rbac_adapter_spec.rb +0 -550
  84. data/spec/auth/rbac_config_spec.rb +0 -82
  85. data/spec/auth/role_spec.rb +0 -51
  86. data/spec/auth/session_manager_spec.rb +0 -172
  87. data/spec/auth/session_spec.rb +0 -112
  88. data/spec/auth/user_spec.rb +0 -130
  89. data/spec/comprehensive_edge_cases_spec.rb +0 -1777
  90. data/spec/context_spec.rb +0 -127
  91. data/spec/decision_agent_spec.rb +0 -96
  92. data/spec/decision_spec.rb +0 -423
  93. data/spec/dsl/condition_evaluator_spec.rb +0 -774
  94. data/spec/dsl_validation_spec.rb +0 -648
  95. data/spec/edge_cases_spec.rb +0 -353
  96. data/spec/evaluation_spec.rb +0 -364
  97. data/spec/evaluation_validator_spec.rb +0 -165
  98. data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
  99. data/spec/examples.txt +0 -1633
  100. data/spec/issue_verification_spec.rb +0 -759
  101. data/spec/json_rule_evaluator_spec.rb +0 -587
  102. data/spec/monitoring/alert_manager_spec.rb +0 -378
  103. data/spec/monitoring/metrics_collector_spec.rb +0 -499
  104. data/spec/monitoring/monitored_agent_spec.rb +0 -222
  105. data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
  106. data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
  107. data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
  108. data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
  109. data/spec/performance_optimizations_spec.rb +0 -486
  110. data/spec/replay_edge_cases_spec.rb +0 -699
  111. data/spec/replay_spec.rb +0 -210
  112. data/spec/rfc8785_canonicalization_spec.rb +0 -215
  113. data/spec/scoring_spec.rb +0 -225
  114. data/spec/spec_helper.rb +0 -60
  115. data/spec/testing/batch_test_importer_spec.rb +0 -693
  116. data/spec/testing/batch_test_runner_spec.rb +0 -307
  117. data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
  118. data/spec/testing/test_result_comparator_spec.rb +0 -392
  119. data/spec/testing/test_scenario_spec.rb +0 -113
  120. data/spec/thread_safety_spec.rb +0 -482
  121. data/spec/thread_safety_spec.rb.broken +0 -878
  122. data/spec/versioning/adapter_spec.rb +0 -156
  123. data/spec/versioning_spec.rb +0 -1030
  124. data/spec/web/middleware/auth_middleware_spec.rb +0 -133
  125. data/spec/web/middleware/permission_middleware_spec.rb +0 -247
  126. data/spec/web_ui_rack_spec.rb +0 -1840
@@ -0,0 +1,819 @@
1
+ require_relative "../errors"
2
+ require_relative "simple_parser"
3
+ require_relative "parser"
4
+ require_relative "transformer"
5
+ require_relative "functions"
6
+ require_relative "types"
7
+ require_relative "../../dsl/condition_evaluator"
8
+
9
+ module DecisionAgent
10
+ module Dmn
11
+ module Feel
12
+ # FEEL expression evaluator with hybrid parsing strategy
13
+ # Phase 2A: Basic comparisons, ranges, list membership (regex-based)
14
+ # Phase 2B: Arithmetic, logical operators, functions (enhanced parser)
15
+ # Maps FEEL expressions to DecisionAgent ConditionEvaluator
16
+ # rubocop:disable Metrics/ClassLength
17
+ class Evaluator
18
+ def initialize
19
+ @simple_parser = SimpleParser.new
20
+ @parslet_parser = Parser.new
21
+ @transformer = Transformer.new
22
+ @cache = {}
23
+ @cache_mutex = Mutex.new
24
+ @ast_cache = {} # Cache for Parslet AST nodes
25
+ @use_parslet = true # Enable full Parslet parser
26
+ end
27
+
28
+ # Evaluate a FEEL expression against a context
29
+ # @param expression [String] FEEL expression (e.g., ">= 18", "in [1,2,3]", "age + 5")
30
+ # @param field_name [String] The field name being evaluated
31
+ # @param context [Hash] Evaluation context
32
+ # @return [Boolean] Evaluation result
33
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
34
+ def evaluate(expression, field_name, context)
35
+ return true if expression == "-" # DMN "don't care" marker
36
+
37
+ # Try Parslet parser first (Phase 2B)
38
+ if @use_parslet
39
+ begin
40
+ expr_key = expression.to_s.strip
41
+
42
+ # Check AST cache first
43
+ ast = @cache_mutex.synchronize do
44
+ @ast_cache[expr_key]
45
+ end
46
+
47
+ if ast.nil?
48
+ parse_tree = @parslet_parser.parse(expr_key)
49
+ ast = @transformer.apply(parse_tree)
50
+ @cache_mutex.synchronize do
51
+ @ast_cache[expr_key] = ast
52
+ end
53
+ end
54
+
55
+ result = evaluate_ast_node(ast, context)
56
+ # If result is nil and AST is a simple field reference that doesn't exist in context,
57
+ # fall back to Phase 2A approach to return condition structure
58
+ unless result.nil? && ast.is_a?(Hash) && ast[:type] == :field &&
59
+ !context.key?(ast[:name]) && !context.key?(ast[:name].to_s) &&
60
+ !context.key?(ast[:name].to_sym)
61
+ return result
62
+ end
63
+ # Fall through to Phase 2A
64
+ rescue Parslet::ParseFailed, FeelParseError, FeelTransformError => e
65
+ # Fall back to Phase 2A approach
66
+ warn "Parslet parse failed: #{e.message}, falling back" if ENV["DEBUG_FEEL"]
67
+ end
68
+ end
69
+
70
+ # Phase 2A approach: use condition structures
71
+ # Check cache first (thread-safe)
72
+ cache_key = "#{expression}::#{field_name}"
73
+ condition = @cache_mutex.synchronize do
74
+ @cache[cache_key]
75
+ end
76
+
77
+ return Dsl::ConditionEvaluator.evaluate(condition, context) if condition
78
+
79
+ # Parse and translate expression to condition structure
80
+ expr_str = expression.to_s.strip
81
+
82
+ # Check if expression matches any known pattern that can be successfully parsed
83
+ is_supported = literal?(expr_str) ||
84
+ comparison_expression?(expr_str) ||
85
+ list_expression?(expr_str) ||
86
+ range_expression?(expr_str)
87
+
88
+ # For SimpleParser, check if it can actually parse successfully
89
+ if SimpleParser.can_parse?(expr_str)
90
+ begin
91
+ @simple_parser.parse(expr_str)
92
+ is_supported = true
93
+ rescue FeelParseError
94
+ # SimpleParser says it can parse, but actually can't - not supported
95
+ is_supported = false
96
+ end
97
+ end
98
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
99
+
100
+ condition = parse_expression_to_condition(expression, field_name, context)
101
+
102
+ # If parse_expression_to_condition returned nil, create default condition structure
103
+ unless condition.is_a?(Hash)
104
+ condition = {
105
+ "field" => field_name,
106
+ "op" => "eq",
107
+ "value" => parse_value(expr_str)
108
+ }
109
+ end
110
+
111
+ # Store in cache (thread-safe)
112
+ @cache_mutex.synchronize do
113
+ @cache[cache_key] = condition
114
+ end
115
+
116
+ # For completely unsupported expressions (no patterns matched), return condition structure
117
+ # This allows fallback to literal equality for unknown syntax
118
+ return condition unless is_supported
119
+
120
+ # Delegate to existing ConditionEvaluator for supported expressions
121
+ evaluation_result = Dsl::ConditionEvaluator.evaluate(condition, context)
122
+
123
+ # If evaluation returns false for a simple equality check and the field doesn't exist in context,
124
+ # treat as unsupported expression and return condition structure (fallback behavior)
125
+ if evaluation_result == false && condition["op"] == "eq"
126
+ field_key = condition["field"]
127
+ field_exists = context.key?(field_key) || context.key?(field_key.to_s) || context.key?(field_key.to_sym)
128
+ return condition unless field_exists
129
+ end
130
+
131
+ # If evaluation returns nil, return condition structure as fallback
132
+ return condition if evaluation_result.nil?
133
+
134
+ evaluation_result
135
+ end
136
+
137
+ # Parse FEEL expression into operator and value (for internal use by Adapter)
138
+ # This maintains backward compatibility with Phase 2A
139
+ def parse_expression(expr)
140
+ expr = expr.to_s.strip
141
+
142
+ # Handle literal values (quoted strings, numbers, booleans)
143
+ return parse_literal(expr) if literal?(expr)
144
+
145
+ # Handle comparison operators
146
+ return parse_comparison(expr) if comparison_expression?(expr)
147
+
148
+ # Handle list membership
149
+ return parse_list_membership(expr) if list_expression?(expr)
150
+
151
+ # Handle range expressions
152
+ return parse_range(expr) if range_expression?(expr)
153
+
154
+ # Check if it's a simple parsable expression (arithmetic/logical)
155
+ if SimpleParser.can_parse?(expr)
156
+ begin
157
+ ast = @simple_parser.parse(expr)
158
+ return translate_ast(ast, nil)
159
+ rescue FeelParseError
160
+ # Fall back to literal equality
161
+ end
162
+ end
163
+
164
+ # Default: equality
165
+ { operator: "eq", value: parse_value(expr) }
166
+ end
167
+
168
+ private
169
+
170
+ def literal?(expr)
171
+ # Quoted string
172
+ return true if expr.start_with?('"') && expr.end_with?('"')
173
+ # Number
174
+ return true if expr.match?(/^-?\d+(\.\d+)?$/)
175
+ # Boolean
176
+ return true if %w[true false].include?(expr.downcase)
177
+
178
+ false
179
+ end
180
+
181
+ def parse_literal(expr)
182
+ if expr.start_with?('"') && expr.end_with?('"')
183
+ # String literal
184
+ { operator: "eq", value: expr[1..-2] }
185
+ elsif expr.match?(/^-?\d+\.\d+$/)
186
+ # Float
187
+ { operator: "eq", value: expr.to_f }
188
+ elsif expr.match?(/^-?\d+$/)
189
+ # Integer
190
+ { operator: "eq", value: expr.to_i }
191
+ elsif expr.downcase == "true"
192
+ { operator: "eq", value: true }
193
+ elsif expr.downcase == "false"
194
+ { operator: "eq", value: false }
195
+ else
196
+ { operator: "eq", value: expr }
197
+ end
198
+ end
199
+
200
+ def comparison_expression?(expr)
201
+ expr.match?(/^(>=|<=|>|<|!=|=)/)
202
+ end
203
+
204
+ def parse_comparison(expr)
205
+ # Extract operator
206
+ operator_match = expr.match(/^(>=|<=|>|<|!=|=)\s*(.+)/)
207
+ return { operator: "eq", value: expr } unless operator_match
208
+
209
+ feel_op = operator_match[1]
210
+ value_str = operator_match[2]
211
+
212
+ # Map FEEL operator to ConditionEvaluator operator
213
+ condition_op = case feel_op
214
+ when ">=" then "gte"
215
+ when "<=" then "lte"
216
+ when ">" then "gt"
217
+ when "<" then "lt"
218
+ when "!=" then "neq"
219
+ when "=" then "eq"
220
+ else "eq"
221
+ end
222
+
223
+ { operator: condition_op, value: parse_value(value_str) }
224
+ end
225
+
226
+ def list_expression?(expr)
227
+ expr.match?(/\[.+\]/)
228
+ end
229
+
230
+ def parse_list_membership(expr)
231
+ # Handle "in [1, 2, 3]" or just "[1, 2, 3]"
232
+ list_match = expr.match(/(?:in\s+)?\[(.+)\]/)
233
+ return { operator: "eq", value: expr } unless list_match
234
+
235
+ items_str = list_match[1]
236
+ items = items_str.split(",").map { |item| parse_value(item.strip) }
237
+
238
+ { operator: "in", value: items }
239
+ end
240
+
241
+ def range_expression?(expr)
242
+ # FEEL ranges like "[10..20]", "(10..20)", etc.
243
+ expr.match?(/[\[(]\d+(\.\d+)?\.\.\d+(\.\d+)?[\])]/)
244
+ end
245
+
246
+ def parse_range(expr)
247
+ # Parse FEEL range syntax: [min..max], (min..max), [min..max), (min..max]
248
+ range_match = expr.match(/([\[(])(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)([\])])/)
249
+ return { operator: "eq", value: expr } unless range_match
250
+
251
+ inclusive_start = range_match[1] == "["
252
+ min_val = parse_value(range_match[2])
253
+ max_val = parse_value(range_match[3])
254
+ inclusive_end = range_match[4] == "]"
255
+
256
+ # For Phase 2A, we support fully inclusive ranges with 'between' operator
257
+ if inclusive_start && inclusive_end
258
+ { operator: "between", value: [min_val, max_val] }
259
+ else
260
+ # For half-open ranges, convert to inclusive by adjusting bounds
261
+ # [min..max) becomes [min..max-1] (if max is integer) or use compound conditions
262
+ # For simplicity, we'll convert to inclusive ranges with adjusted bounds
263
+ # This is a pragmatic approach for Phase 2A
264
+ adjusted_min = if inclusive_start
265
+ min_val
266
+ elsif min_val.is_a?(Integer)
267
+ min_val + 1
268
+ else
269
+ min_val + 0.0001
270
+ end
271
+ adjusted_max = if inclusive_end
272
+ max_val
273
+ elsif max_val.is_a?(Integer)
274
+ max_val - 1
275
+ else
276
+ max_val - 0.0001
277
+ end
278
+
279
+ # Ensure adjusted range is valid
280
+ if adjusted_min <= adjusted_max
281
+ { operator: "between", value: [adjusted_min, adjusted_max] }
282
+ else
283
+ # Invalid range, fall back to error
284
+ raise FeelParseError,
285
+ "Invalid half-open range: #{expr}. Range would be empty after adjustment."
286
+ end
287
+ end
288
+ end
289
+
290
+ def parse_value(str)
291
+ str = str.to_s.strip
292
+
293
+ # Remove quotes
294
+ return str[1..-2] if str.start_with?('"') && str.end_with?('"')
295
+
296
+ # Try to parse as number
297
+ if str.match?(/^-?\d+\.\d+$/)
298
+ return str.to_f
299
+ elsif str.match?(/^-?\d+$/)
300
+ return str.to_i
301
+ end
302
+
303
+ # Boolean
304
+ return true if str.downcase == "true"
305
+ return false if str.downcase == "false"
306
+
307
+ # Return as string
308
+ str
309
+ end
310
+
311
+ # Parse expression to condition structure (Phase 2A backward compatibility)
312
+ def parse_expression_to_condition(expression, field_name, context)
313
+ expr = expression.to_s.strip
314
+
315
+ # Try Phase 2A patterns
316
+ if literal?(expr) || comparison_expression?(expr) || list_expression?(expr) || range_expression?(expr)
317
+ parsed = parse_expression(expr)
318
+ return {
319
+ "field" => field_name,
320
+ "op" => parsed[:operator],
321
+ "value" => parsed[:value]
322
+ }
323
+ end
324
+
325
+ # Try simple parser for arithmetic/logical expressions
326
+ if SimpleParser.can_parse?(expr)
327
+ begin
328
+ ast = @simple_parser.parse(expr)
329
+ translated = translate_ast(ast, field_name, context)
330
+ # If translate_ast returns a valid Hash, return it
331
+ # Otherwise, fall through to default literal equality
332
+ return translated if translated.is_a?(Hash)
333
+ rescue FeelParseError, StandardError => e
334
+ warn "FEEL parse warning: #{e.message}, falling back to literal match" if ENV["DEBUG_FEEL"]
335
+ # Fall through to default
336
+ end
337
+ end
338
+
339
+ # Default: literal equality - always return a Hash
340
+ {
341
+ "field" => field_name,
342
+ "op" => "eq",
343
+ "value" => parse_value(expr)
344
+ }
345
+ end
346
+
347
+ # Translate AST to ConditionEvaluator format
348
+ def translate_ast(node, field_name, context = {})
349
+ case node[:type]
350
+ when :literal
351
+ # Just a value
352
+ return node[:value] if field_name.nil?
353
+
354
+ { "field" => field_name, "op" => "eq", "value" => node[:value] }
355
+
356
+ when :field
357
+ # Field reference - get value from context
358
+ context.to_h[node[:name].to_sym] || context.to_h[node[:name]]
359
+
360
+ when :arithmetic
361
+ translate_arithmetic(node, field_name, context)
362
+
363
+ when :logical
364
+ translate_logical(node, field_name, context)
365
+
366
+ when :comparison
367
+ translate_comparison(node, field_name, context)
368
+
369
+ else
370
+ raise FeelEvaluationError.new("Unknown AST node type: #{node[:type]}", expression: node.inspect)
371
+ end
372
+ end
373
+
374
+ # Translate arithmetic operations
375
+ def translate_arithmetic(node, _field_name, context)
376
+ op = node[:operator]
377
+
378
+ if op == "negate"
379
+ # Unary negation
380
+ operand_val = evaluate_ast_value(node[:operand], context)
381
+ return -operand_val
382
+ end
383
+
384
+ # Binary arithmetic
385
+ left_val = evaluate_ast_value(node[:left], context)
386
+ right_val = evaluate_ast_value(node[:right], context)
387
+
388
+ case op
389
+ when "+" then left_val + right_val
390
+ when "-" then left_val - right_val
391
+ when "*" then left_val * right_val
392
+ when "/" then left_val / right_val.to_f
393
+ when "**" then left_val**right_val
394
+ when "%" then left_val % right_val
395
+ else
396
+ raise FeelEvaluationError, "Unknown arithmetic operator: #{op}"
397
+ end
398
+ end
399
+
400
+ # Translate logical operations
401
+ def translate_logical(node, field_name, context)
402
+ op = node[:operator]
403
+
404
+ if op == "not"
405
+ # Unary NOT
406
+ operand = translate_ast(node[:operand], nil, context)
407
+ return { "all" => [{ "field" => field_name, "op" => "eq", "value" => false }] } if operand == true
408
+ return { "all" => [{ "field" => field_name, "op" => "eq", "value" => true }] } if operand == false
409
+
410
+ return !operand
411
+ end
412
+
413
+ # Binary logical (and, or)
414
+ left_condition = ast_to_condition(node[:left], field_name, context)
415
+ right_condition = ast_to_condition(node[:right], field_name, context)
416
+
417
+ case op
418
+ when "and"
419
+ { "all" => [left_condition, right_condition] }
420
+ when "or"
421
+ { "any" => [left_condition, right_condition] }
422
+ else
423
+ raise FeelEvaluationError, "Unknown logical operator: #{op}"
424
+ end
425
+ end
426
+
427
+ # Translate comparison operations
428
+ def translate_comparison(node, _field_name, context)
429
+ left_val = evaluate_ast_value(node[:left], context)
430
+ right_val = evaluate_ast_value(node[:right], context)
431
+ op = node[:operator]
432
+
433
+ # Map FEEL comparison to ConditionEvaluator operator
434
+ condition_op = case op
435
+ when ">=" then "gte"
436
+ when "<=" then "lte"
437
+ when ">" then "gt"
438
+ when "<" then "lt"
439
+ when "!=" then "neq"
440
+ when "=" then "eq"
441
+ else "eq"
442
+ end
443
+
444
+ # If left side is a field reference, use it as the field
445
+ if node[:left][:type] == :field
446
+ return {
447
+ "field" => node[:left][:name],
448
+ "op" => condition_op,
449
+ "value" => right_val
450
+ }
451
+ end
452
+
453
+ # Otherwise, evaluate both sides and return boolean result
454
+ case op
455
+ when ">=" then left_val >= right_val
456
+ when "<=" then left_val <= right_val
457
+ when ">" then left_val > right_val
458
+ when "<" then left_val < right_val
459
+ when "!=" then left_val != right_val
460
+ when "=" then left_val == right_val
461
+ else left_val == right_val
462
+ end
463
+ end
464
+
465
+ # Convert AST node to condition structure
466
+ def ast_to_condition(node, field_name, context)
467
+ case node[:type]
468
+ when :comparison
469
+ translate_comparison(node, field_name, context)
470
+ when :logical
471
+ translate_logical(node, field_name, context)
472
+ when :field
473
+ # Field reference in boolean context
474
+ { "field" => node[:name], "op" => "eq", "value" => true }
475
+ when :literal
476
+ # Literal in boolean context
477
+ { "field" => field_name, "op" => "eq", "value" => node[:value] }
478
+ else
479
+ # Evaluate and create condition
480
+ val = translate_ast(node, nil, context)
481
+ { "field" => field_name, "op" => "eq", "value" => val }
482
+ end
483
+ end
484
+
485
+ # Evaluate AST node to get a value (not a condition)
486
+ def evaluate_ast_value(node, context)
487
+ case node[:type]
488
+ when :literal
489
+ node[:value]
490
+ when :field
491
+ context.to_h[node[:name].to_sym] || context.to_h[node[:name]] || 0
492
+ when :arithmetic
493
+ translate_arithmetic(node, nil, context)
494
+ else
495
+ translate_ast(node, nil, context)
496
+ end
497
+ end
498
+
499
+ # Evaluate Parslet AST node (Phase 2B - full FEEL support)
500
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
501
+ def evaluate_ast_node(node, context)
502
+ return node unless node.is_a?(Hash)
503
+
504
+ # Handle nodes without type - might be raw Parslet output
505
+ if node[:type].nil? || node[:type].to_s.empty?
506
+ # Try to extract value from common Parslet structures
507
+ return node[:value] if node.key?(:value)
508
+ return node[:number] if node.key?(:number)
509
+ return node[:string] if node.key?(:string)
510
+ return node[:boolean] if node.key?(:boolean)
511
+
512
+ return node
513
+ end
514
+
515
+ case node[:type]
516
+ when :number, :string, :boolean
517
+ node[:value]
518
+ when :null
519
+ nil
520
+ when :field
521
+ get_field_value(node[:name], context)
522
+ when :list, :list_literal
523
+ evaluate_list(node, context)
524
+ when :context, :context_literal
525
+ evaluate_context(node, context)
526
+ when :range
527
+ evaluate_range(node, context)
528
+ when :function_call
529
+ evaluate_function_call(node, context)
530
+ when :property_access
531
+ evaluate_property_access(node, context)
532
+ when :comparison
533
+ evaluate_comparison_node?(node, context)
534
+ when :arithmetic
535
+ evaluate_arithmetic_node(node, context)
536
+ when :logical
537
+ evaluate_logical_node(node, context)
538
+ when :conditional
539
+ evaluate_conditional(node, context)
540
+ when :quantified
541
+ evaluate_quantified(node, context)
542
+ when :for
543
+ evaluate_for(node, context)
544
+ when :filter
545
+ evaluate_filter(node, context)
546
+ when :between
547
+ evaluate_between?(node, context)
548
+ when :in
549
+ evaluate_in_node?(node, context)
550
+ when :instance_of
551
+ evaluate_instance_of?(node, context)
552
+ else
553
+ raise FeelEvaluationError, "Unknown AST node type: #{node[:type]}"
554
+ end
555
+ end
556
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
557
+
558
+ # Get field value from context
559
+ def get_field_value(field_name, context)
560
+ ctx = context.to_h
561
+ ctx[field_name.to_sym] || ctx[field_name] || ctx[field_name.to_s]
562
+ end
563
+
564
+ # Evaluate list literal
565
+ def evaluate_list(node, context)
566
+ return [] if node[:elements].nil? || node[:elements].empty?
567
+
568
+ Array(node[:elements]).map { |elem| evaluate_ast_node(elem, context) }
569
+ end
570
+
571
+ # Evaluate context literal
572
+ def evaluate_context(node, context)
573
+ result = {}
574
+ return result if node[:pairs].nil? || node[:pairs].empty?
575
+
576
+ node[:pairs].each do |key, value|
577
+ result[key.to_sym] = evaluate_ast_node(value, context)
578
+ end
579
+ result
580
+ end
581
+
582
+ # Evaluate range
583
+ def evaluate_range(node, context)
584
+ start_val = evaluate_ast_node(node[:start], context)
585
+ end_val = evaluate_ast_node(node[:end], context)
586
+
587
+ {
588
+ type: :range,
589
+ start: start_val,
590
+ end: end_val,
591
+ start_inclusive: node[:start_inclusive],
592
+ end_inclusive: node[:end_inclusive]
593
+ }
594
+ end
595
+
596
+ # Evaluate function call
597
+ def evaluate_function_call(node, context)
598
+ # Extract function name - could be a string or a field node
599
+ function_name = if node[:name].is_a?(Hash)
600
+ if node[:name][:type] == :field
601
+ node[:name][:name]
602
+ else
603
+ node[:name][:name] || node[:name][:identifier] || node[:name].to_s
604
+ end
605
+ else
606
+ node[:name]
607
+ end
608
+
609
+ args = Array(node[:arguments]).map { |arg| evaluate_ast_node(arg, context) }
610
+
611
+ Functions.execute(function_name.to_s, args, context)
612
+ end
613
+
614
+ # Evaluate property access
615
+ def evaluate_property_access(node, context)
616
+ object = evaluate_ast_node(node[:object], context)
617
+ property = node[:property]
618
+
619
+ case object
620
+ when Hash
621
+ object[property.to_sym] || object[property.to_s] || object[property]
622
+ when Types::Context
623
+ object[property.to_sym]
624
+ else
625
+ object.respond_to?(property) ? object.send(property) : nil
626
+ end
627
+ end
628
+
629
+ # Evaluate comparison node
630
+ def evaluate_comparison_node?(node, context)
631
+ left_val = evaluate_ast_node(node[:left], context)
632
+ right_val = evaluate_ast_node(node[:right], context)
633
+
634
+ case node[:operator]
635
+ when "=" then left_val == right_val
636
+ when "!=" then left_val != right_val
637
+ when "<" then left_val < right_val
638
+ when ">" then left_val > right_val
639
+ when "<=" then left_val <= right_val
640
+ when ">=" then left_val >= right_val
641
+ else false
642
+ end
643
+ end
644
+
645
+ # Evaluate arithmetic node
646
+ def evaluate_arithmetic_node(node, context)
647
+ if node[:operand]
648
+ # Unary operation
649
+ operand_val = evaluate_ast_node(node[:operand], context)
650
+ return node[:operator] == "negate" ? -operand_val : operand_val
651
+ end
652
+
653
+ # Binary operation
654
+ left_val = evaluate_ast_node(node[:left], context)
655
+ right_val = evaluate_ast_node(node[:right], context)
656
+
657
+ case node[:operator]
658
+ when "+" then left_val + right_val
659
+ when "-" then left_val - right_val
660
+ when "*" then left_val * right_val
661
+ when "/" then left_val / right_val.to_f
662
+ when "**" then left_val**right_val
663
+ when "%" then left_val % right_val
664
+ else 0
665
+ end
666
+ end
667
+
668
+ # Evaluate logical node
669
+ def evaluate_logical_node(node, context)
670
+ if node[:operand]
671
+ # Unary NOT
672
+ operand_val = evaluate_ast_node(node[:operand], context)
673
+ return !operand_val
674
+ end
675
+
676
+ # Binary operation
677
+ left_val = evaluate_ast_node(node[:left], context)
678
+
679
+ case node[:operator]
680
+ when "and"
681
+ return false unless left_val
682
+
683
+ right_val = evaluate_ast_node(node[:right], context)
684
+ left_val && right_val
685
+ when "or"
686
+ return true if left_val
687
+
688
+ right_val = evaluate_ast_node(node[:right], context)
689
+ left_val || right_val
690
+ else
691
+ false
692
+ end
693
+ end
694
+
695
+ # Evaluate if/then/else conditional
696
+ def evaluate_conditional(node, context)
697
+ condition_val = evaluate_ast_node(node[:condition], context)
698
+
699
+ if condition_val
700
+ evaluate_ast_node(node[:then_expr], context)
701
+ else
702
+ evaluate_ast_node(node[:else_expr], context)
703
+ end
704
+ end
705
+
706
+ # Evaluate quantified expression (some/every)
707
+ def evaluate_quantified(node, context)
708
+ list_val = evaluate_ast_node(node[:list], context)
709
+ return false unless list_val.is_a?(Array) || list_val.is_a?(Types::List)
710
+
711
+ variable = node[:variable]
712
+
713
+ case node[:quantifier]
714
+ when "some"
715
+ Array(list_val).any? do |item|
716
+ item_context = context.to_h.merge(variable.to_sym => item)
717
+ evaluate_ast_node(node[:condition], item_context)
718
+ end
719
+ when "every"
720
+ Array(list_val).all? do |item|
721
+ item_context = context.to_h.merge(variable.to_sym => item)
722
+ evaluate_ast_node(node[:condition], item_context)
723
+ end
724
+ else
725
+ false
726
+ end
727
+ end
728
+
729
+ # Evaluate for expression
730
+ def evaluate_for(node, context)
731
+ list_val = evaluate_ast_node(node[:list], context)
732
+ return [] unless list_val.is_a?(Array) || list_val.is_a?(Types::List)
733
+
734
+ variable = node[:variable]
735
+
736
+ Array(list_val).map do |item|
737
+ item_context = context.to_h.merge(variable.to_sym => item)
738
+ evaluate_ast_node(node[:return_expr], item_context)
739
+ end
740
+ end
741
+
742
+ # Evaluate filter expression
743
+ def evaluate_filter(node, context)
744
+ list_val = evaluate_ast_node(node[:list], context)
745
+ return [] unless list_val.is_a?(Array) || list_val.is_a?(Types::List)
746
+
747
+ Array(list_val).select do |item|
748
+ # For filter, use 'item' as the implicit variable
749
+ item_context = if item.is_a?(Hash)
750
+ context.to_h.merge(item)
751
+ else
752
+ context.to_h.merge(item: item)
753
+ end
754
+ evaluate_ast_node(node[:condition], item_context)
755
+ end
756
+ end
757
+
758
+ # Evaluate between expression
759
+ def evaluate_between?(node, context)
760
+ value = evaluate_ast_node(node[:value], context)
761
+ min_val = evaluate_ast_node(node[:min], context)
762
+ max_val = evaluate_ast_node(node[:max], context)
763
+
764
+ value.between?(min_val, max_val)
765
+ end
766
+
767
+ # Evaluate in expression
768
+ def evaluate_in_node?(node, context)
769
+ value = evaluate_ast_node(node[:value], context)
770
+ list_val = evaluate_ast_node(node[:list], context)
771
+
772
+ if list_val.is_a?(Array) || list_val.is_a?(Types::List)
773
+ Array(list_val).include?(value)
774
+ elsif list_val.is_a?(Hash) && list_val[:type] == :range
775
+ # Check if value is in range
776
+ in_range?(value, list_val)
777
+ else
778
+ false
779
+ end
780
+ end
781
+
782
+ # Evaluate instance of expression
783
+ def evaluate_instance_of?(node, context)
784
+ value = evaluate_ast_node(node[:value], context)
785
+ type_name = node[:type_name]
786
+
787
+ case type_name
788
+ when "number"
789
+ value.is_a?(Numeric)
790
+ when "string"
791
+ value.is_a?(String)
792
+ when "boolean"
793
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
794
+ when "date"
795
+ value.is_a?(Types::Date) || value.is_a?(Date) || value.is_a?(Time)
796
+ when "time"
797
+ value.is_a?(Types::Time) || value.is_a?(Time)
798
+ when "duration"
799
+ value.is_a?(Types::Duration)
800
+ when "list"
801
+ value.is_a?(Array) || value.is_a?(Types::List)
802
+ when "context"
803
+ value.is_a?(Hash) || value.is_a?(Types::Context)
804
+ else
805
+ false
806
+ end
807
+ end
808
+
809
+ # Check if value is in range
810
+ def in_range?(value, range)
811
+ start_check = range[:start_inclusive] ? value >= range[:start] : value > range[:start]
812
+ end_check = range[:end_inclusive] ? value <= range[:end] : value < range[:end]
813
+ start_check && end_check
814
+ end
815
+ end
816
+ # rubocop:enable Metrics/ClassLength
817
+ end
818
+ end
819
+ end