decision_agent 0.2.0 → 0.3.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -1
  3. data/bin/decision_agent +104 -0
  4. data/lib/decision_agent/dmn/adapter.rb +135 -0
  5. data/lib/decision_agent/dmn/cache.rb +306 -0
  6. data/lib/decision_agent/dmn/decision_graph.rb +327 -0
  7. data/lib/decision_agent/dmn/decision_tree.rb +192 -0
  8. data/lib/decision_agent/dmn/errors.rb +30 -0
  9. data/lib/decision_agent/dmn/exporter.rb +217 -0
  10. data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
  11. data/lib/decision_agent/dmn/feel/functions.rb +420 -0
  12. data/lib/decision_agent/dmn/feel/parser.rb +349 -0
  13. data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
  14. data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
  15. data/lib/decision_agent/dmn/feel/types.rb +276 -0
  16. data/lib/decision_agent/dmn/importer.rb +77 -0
  17. data/lib/decision_agent/dmn/model.rb +197 -0
  18. data/lib/decision_agent/dmn/parser.rb +191 -0
  19. data/lib/decision_agent/dmn/testing.rb +333 -0
  20. data/lib/decision_agent/dmn/validator.rb +315 -0
  21. data/lib/decision_agent/dmn/versioning.rb +229 -0
  22. data/lib/decision_agent/dmn/visualizer.rb +513 -0
  23. data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
  24. data/lib/decision_agent/dsl/schema_validator.rb +2 -1
  25. data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
  26. data/lib/decision_agent/version.rb +1 -1
  27. data/lib/decision_agent/web/dmn_editor.rb +426 -0
  28. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  29. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  30. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  31. data/lib/decision_agent/web/public/index.html +3 -0
  32. data/lib/decision_agent/web/public/styles.css +21 -0
  33. data/lib/decision_agent/web/server.rb +465 -0
  34. data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
  35. data/spec/auth/rbac_adapter_spec.rb +228 -0
  36. data/spec/dmn/decision_graph_spec.rb +282 -0
  37. data/spec/dmn/decision_tree_spec.rb +203 -0
  38. data/spec/dmn/feel/errors_spec.rb +18 -0
  39. data/spec/dmn/feel/functions_spec.rb +400 -0
  40. data/spec/dmn/feel/simple_parser_spec.rb +274 -0
  41. data/spec/dmn/feel/types_spec.rb +176 -0
  42. data/spec/dmn/feel_parser_spec.rb +489 -0
  43. data/spec/dmn/hit_policy_spec.rb +202 -0
  44. data/spec/dmn/integration_spec.rb +226 -0
  45. data/spec/examples.txt +1846 -1570
  46. data/spec/fixtures/dmn/complex_decision.dmn +81 -0
  47. data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
  48. data/spec/fixtures/dmn/simple_decision.dmn +40 -0
  49. data/spec/monitoring/metrics_collector_spec.rb +37 -35
  50. data/spec/monitoring/monitored_agent_spec.rb +14 -11
  51. data/spec/performance_optimizations_spec.rb +10 -3
  52. data/spec/thread_safety_spec.rb +10 -2
  53. data/spec/web_ui_rack_spec.rb +294 -0
  54. metadata +65 -1
@@ -0,0 +1,372 @@
1
+ require "parslet"
2
+ require_relative "../errors"
3
+
4
+ module DecisionAgent
5
+ module Dmn
6
+ module Feel
7
+ # Transforms Parslet parse tree into AST
8
+ class Transformer < Parslet::Transform
9
+ # Literals
10
+ rule(null: simple(:_)) { { type: :null, value: nil } }
11
+
12
+ rule(boolean: simple(:val)) do
13
+ { type: :boolean, value: val.to_s == "true" }
14
+ end
15
+
16
+ rule(number: simple(:val)) do
17
+ str = val.to_s
18
+ value = str.include?(".") ? str.to_f : str.to_i
19
+ { type: :number, value: value }
20
+ end
21
+
22
+ rule(string: simple(:val)) do
23
+ { type: :string, value: val.to_s }
24
+ end
25
+
26
+ # Argument wrapper (unwrap the arg node to get the inner expression)
27
+ rule(arg: subtree(:expr)) do
28
+ expr
29
+ end
30
+
31
+ # List literal
32
+ rule(list_literal: { list: subtree(:items) }) do
33
+ items_array = case items
34
+ when Array then items
35
+ when Hash then [items]
36
+ when nil then []
37
+ else [items]
38
+ end
39
+ { type: :list_literal, elements: items_array }
40
+ end
41
+
42
+ # Context entry (unwrap the entry wrapper)
43
+ rule(entry: { key: subtree(:k), value: subtree(:v) }) do
44
+ { key: k, value: v }
45
+ end
46
+
47
+ # Context literal
48
+ rule(context_literal: { context: subtree(:entries) }) do
49
+ entries_array = case entries
50
+ when Array then entries
51
+ when Hash then [entries]
52
+ when nil then []
53
+ else [entries]
54
+ end
55
+
56
+ pairs = entries_array.map do |entry|
57
+ # Extract key - could be a transformed field node, string node, or raw value
58
+ key = if entry[:key].is_a?(Hash)
59
+ # Key is a structured node
60
+ case entry[:key][:type]
61
+ when :field
62
+ entry[:key][:name].to_s
63
+ when :string
64
+ entry[:key][:value].to_s
65
+ when :identifier
66
+ entry[:key][:name].to_s
67
+ else
68
+ entry[:key][:identifier]&.to_s || entry[:key][:string]&.to_s || entry[:key].to_s
69
+ end
70
+ elsif entry[:key].is_a?(Parslet::Slice)
71
+ entry[:key].to_s
72
+ else
73
+ entry[:key].to_s
74
+ end
75
+
76
+ [key, entry[:value]]
77
+ end
78
+
79
+ { type: :context_literal, pairs: pairs }
80
+ end
81
+
82
+ # Range literal
83
+ rule(range: {
84
+ start_bracket: simple(:sb),
85
+ start: subtree(:s),
86
+ end: subtree(:e),
87
+ end_bracket: simple(:eb)
88
+ }) do
89
+ {
90
+ type: :range,
91
+ start: s,
92
+ end: e,
93
+ start_inclusive: sb.to_s == "[",
94
+ end_inclusive: eb.to_s == "]"
95
+ }
96
+ end
97
+
98
+ # Identifier
99
+ rule(identifier: simple(:name)) do
100
+ { type: :field, name: name.to_s.strip }
101
+ end
102
+
103
+ # Identifier or function call (with arguments)
104
+ rule(identifier_or_call: { name: subtree(:name), arguments: subtree(:args) }) do
105
+ # It's a function call
106
+ args_array = case args
107
+ when Array then args
108
+ when Hash then args.empty? ? [] : [args]
109
+ when nil then []
110
+ else [args]
111
+ end
112
+
113
+ func_name = case name
114
+ when Hash
115
+ # Handle transformed field nodes or raw identifier hashes
116
+ if name[:type] == :field
117
+ name[:name].to_s.strip
118
+ else
119
+ name[:identifier]&.to_s&.strip || name.to_s
120
+ end
121
+ else
122
+ name.to_s.strip
123
+ end
124
+
125
+ {
126
+ type: :function_call,
127
+ name: func_name,
128
+ arguments: args_array
129
+ }
130
+ end
131
+
132
+ # Identifier or function call (just identifier, no arguments)
133
+ rule(identifier_or_call: { name: subtree(:name) }) do
134
+ # Just an identifier
135
+ field_name = case name
136
+ when Hash
137
+ name[:identifier]&.to_s&.strip || name[:type] == :field ? name[:name] : name.to_s
138
+ else
139
+ name.to_s.strip
140
+ end
141
+
142
+ { type: :field, name: field_name }
143
+ end
144
+
145
+ # Comparison operations
146
+ rule(comparison: { left: subtree(:l), op: simple(:o), right: subtree(:r) }) do
147
+ {
148
+ type: :comparison,
149
+ operator: o.to_s,
150
+ left: l,
151
+ right: r
152
+ }
153
+ end
154
+
155
+ # Between expression
156
+ rule(between: { value: subtree(:val), min: subtree(:min), max: subtree(:max) }) do
157
+ {
158
+ type: :between,
159
+ value: val,
160
+ min: min,
161
+ max: max
162
+ }
163
+ end
164
+
165
+ # In expression
166
+ rule(in: { value: subtree(:val), list: subtree(:list) }) do
167
+ {
168
+ type: :in,
169
+ value: val,
170
+ list: list
171
+ }
172
+ end
173
+
174
+ # Instance of
175
+ rule(instance_of: { value: subtree(:val), type: simple(:t) }) do
176
+ {
177
+ type: :instance_of,
178
+ value: val,
179
+ type_name: t.to_s
180
+ }
181
+ end
182
+
183
+ # Arithmetic operations
184
+ rule(arithmetic: { left: subtree(:l), op: simple(:o), right: subtree(:r) }) do
185
+ {
186
+ type: :arithmetic,
187
+ operator: o.to_s,
188
+ left: l,
189
+ right: r
190
+ }
191
+ end
192
+
193
+ rule(term: { left: subtree(:l), op: simple(:o), right: subtree(:r) }) do
194
+ {
195
+ type: :arithmetic,
196
+ operator: o.to_s,
197
+ left: l,
198
+ right: r
199
+ }
200
+ end
201
+
202
+ rule(exponentiation: { left: subtree(:l), op: simple(:o), right: subtree(:r) }) do
203
+ {
204
+ type: :arithmetic,
205
+ operator: o.to_s,
206
+ left: l,
207
+ right: r
208
+ }
209
+ end
210
+
211
+ # Unary operations
212
+ rule(unary: { op: simple(:o), operand: subtree(:operand) }) do
213
+ if o.to_s == "not"
214
+ {
215
+ type: :logical,
216
+ operator: "not",
217
+ operand: operand
218
+ }
219
+ elsif o.to_s == "-" && operand.is_a?(Hash) && operand[:type] == :number
220
+ # Special case: unary minus on a number literal -> negative number literal
221
+ {
222
+ type: :number,
223
+ value: -operand[:value]
224
+ }
225
+ else
226
+ {
227
+ type: :arithmetic,
228
+ operator: "negate",
229
+ operand: operand
230
+ }
231
+ end
232
+ end
233
+
234
+ # Logical operations
235
+ rule(or: { left: subtree(:l), or_ops: subtree(:ops) }) do
236
+ ops_array = Array(ops)
237
+ # Build nested or structure
238
+ ops_array.reduce(l) do |left_side, op|
239
+ {
240
+ type: :logical,
241
+ operator: "or",
242
+ left: left_side,
243
+ right: op[:right]
244
+ }
245
+ end
246
+ end
247
+
248
+ rule(and: { left: subtree(:l), and_ops: subtree(:ops) }) do
249
+ ops_array = Array(ops)
250
+ # Build nested and structure
251
+ ops_array.reduce(l) do |left_side, op|
252
+ {
253
+ type: :logical,
254
+ operator: "and",
255
+ left: left_side,
256
+ right: op[:right]
257
+ }
258
+ end
259
+ end
260
+
261
+ # Postfix operations (property access, function calls, filters)
262
+ rule(postfix: { base: subtree(:base), postfix_ops: subtree(:ops) }) do
263
+ ops_array = Array(ops)
264
+ ops_array.reduce(base) do |current, op|
265
+ case op
266
+ when Hash
267
+ if op[:property_access]
268
+ {
269
+ type: :property_access,
270
+ object: current,
271
+ property: op[:property_access][:property][:identifier].to_s
272
+ }
273
+ elsif op[:function_call]
274
+ {
275
+ type: :function_call,
276
+ name: current,
277
+ arguments: op[:function_call][:arguments] || []
278
+ }
279
+ elsif op[:filter]
280
+ {
281
+ type: :filter,
282
+ list: current,
283
+ condition: op[:filter][:filter]
284
+ }
285
+ else
286
+ current
287
+ end
288
+ else
289
+ current
290
+ end
291
+ end
292
+ end
293
+
294
+ # If-then-else conditional
295
+ rule(condition: subtree(:c), then_expr: subtree(:t), else_expr: subtree(:e)) do
296
+ {
297
+ type: :conditional,
298
+ condition: c,
299
+ then_expr: t,
300
+ else_expr: e
301
+ }
302
+ end
303
+
304
+ # Quantified expressions
305
+ rule(quantifier: simple(:q), var: subtree(:v), list: subtree(:l), condition: subtree(:c)) do
306
+ # Variable might be already transformed to a field node or still be an identifier hash
307
+ var_name = if v.is_a?(Hash) && v[:type] == :field
308
+ v[:name]
309
+ elsif v.is_a?(Hash) && v[:identifier]
310
+ v[:identifier].to_s
311
+ else
312
+ v.to_s
313
+ end
314
+
315
+ {
316
+ type: :quantified,
317
+ quantifier: q.to_s,
318
+ variable: var_name,
319
+ list: l,
320
+ condition: c
321
+ }
322
+ end
323
+
324
+ # For expression
325
+ rule(var: subtree(:v), list: subtree(:l), return_expr: subtree(:r)) do
326
+ # Variable might be already transformed to a field node or still be an identifier hash
327
+ var_name = if v.is_a?(Hash) && v[:type] == :field
328
+ v[:name]
329
+ elsif v.is_a?(Hash) && v[:identifier]
330
+ v[:identifier].to_s
331
+ else
332
+ v.to_s
333
+ end
334
+
335
+ {
336
+ type: :for,
337
+ variable: var_name,
338
+ list: l,
339
+ return_expr: r
340
+ }
341
+ end
342
+
343
+ # Function definition
344
+ rule(function_def: { params: subtree(:params), body: subtree(:body) }) do
345
+ params_array = case params
346
+ when Array then params.map { |p| p[:param][:identifier].to_s }
347
+ when Hash then [params[:param][:identifier].to_s]
348
+ when nil then []
349
+ else []
350
+ end
351
+
352
+ {
353
+ type: :function_definition,
354
+ parameters: params_array,
355
+ body: body
356
+ }
357
+ end
358
+
359
+ # Helper to convert parse tree to AST
360
+ def self.to_ast(parse_tree)
361
+ new.apply(parse_tree)
362
+ rescue StandardError => e
363
+ raise FeelTransformError.new(
364
+ "Failed to transform parse tree to AST: #{e.message}",
365
+ parse_tree: parse_tree,
366
+ error: e
367
+ )
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,276 @@
1
+ require "time"
2
+ require "date"
3
+ require "bigdecimal"
4
+ require_relative "../errors"
5
+
6
+ module DecisionAgent
7
+ module Dmn
8
+ module Feel
9
+ # Type system for FEEL values
10
+ # Provides thin wrappers around Ruby types to maintain FEEL semantics
11
+ module Types
12
+ # Wrapper for numbers with precision tracking
13
+ class Number
14
+ attr_reader :value, :scale
15
+
16
+ def initialize(value, scale: nil)
17
+ @value = case value
18
+ when BigDecimal then value
19
+ when String then BigDecimal(value)
20
+ when Integer, Float then value
21
+ else
22
+ raise FeelTypeError, "Cannot convert #{value.class} to Number: #{value.inspect}"
23
+ end
24
+ @scale = scale
25
+ end
26
+
27
+ def to_ruby
28
+ @value
29
+ end
30
+
31
+ def to_i
32
+ @value.to_i
33
+ end
34
+
35
+ def to_f
36
+ @value.to_f
37
+ end
38
+
39
+ def ==(other)
40
+ return @value == other.value if other.is_a?(Number)
41
+
42
+ @value == other
43
+ end
44
+
45
+ def inspect
46
+ "#<Feel::Number #{@value}#{" (scale: #{@scale})" if @scale}>"
47
+ end
48
+ end
49
+
50
+ # Wrapper for dates (ISO 8601)
51
+ class Date
52
+ attr_reader :value
53
+
54
+ def initialize(value)
55
+ @value = case value
56
+ when ::Time, ::Date, ::DateTime then value
57
+ when String then parse_date(value)
58
+ else
59
+ raise FeelTypeError, "Cannot convert #{value.class} to Date: #{value.inspect}"
60
+ end
61
+ end
62
+
63
+ def to_ruby
64
+ @value
65
+ end
66
+
67
+ def ==(other)
68
+ return @value == other.value if other.is_a?(Date)
69
+
70
+ @value == other
71
+ end
72
+
73
+ def inspect
74
+ "#<Feel::Date #{@value}>"
75
+ end
76
+
77
+ private
78
+
79
+ def parse_date(str)
80
+ # Try ISO 8601 formats first
81
+ iso_result = begin
82
+ ::Time.iso8601(str)
83
+ rescue ArgumentError
84
+ nil
85
+ end
86
+
87
+ return iso_result if iso_result
88
+
89
+ # Fall back to Date parsing
90
+ ::Date.parse(str).to_time
91
+ rescue ::Date::Error => e
92
+ raise FeelTypeError, "Invalid date format: #{e.message}"
93
+ end
94
+ end
95
+
96
+ # Wrapper for time values
97
+ class Time
98
+ attr_reader :value
99
+
100
+ def initialize(value)
101
+ @value = case value
102
+ when ::Time then value
103
+ when String then parse_time(value)
104
+ else
105
+ raise FeelTypeError.new(
106
+ "Cannot convert to Time",
107
+ expected_type: "Time",
108
+ actual_type: value.class,
109
+ value: value
110
+ )
111
+ end
112
+ end
113
+
114
+ def to_ruby
115
+ @value
116
+ end
117
+
118
+ def ==(other)
119
+ return @value == other.value if other.is_a?(Time)
120
+
121
+ @value == other
122
+ end
123
+
124
+ def inspect
125
+ "#<Feel::Time #{@value}>"
126
+ end
127
+
128
+ private
129
+
130
+ def parse_time(str)
131
+ # Try ISO 8601 time parsing
132
+ ::Time.iso8601(str)
133
+ rescue ArgumentError => e
134
+ raise FeelTypeError.new(
135
+ "Invalid time format: #{e.message}",
136
+ expected_type: "ISO 8601 time",
137
+ value: str
138
+ )
139
+ end
140
+ end
141
+
142
+ # Wrapper for durations (ISO 8601: P1Y2M3DT4H5M6S)
143
+ class Duration
144
+ attr_reader :years, :months, :days, :hours, :minutes, :seconds
145
+
146
+ def initialize(years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0)
147
+ @years = years
148
+ @months = months
149
+ @days = days
150
+ @hours = hours
151
+ @minutes = minutes
152
+ @seconds = seconds
153
+ end
154
+
155
+ # Parse ISO 8601 duration string
156
+ # Examples: P1Y, P1M, P1D, PT1H, PT1M, PT1S, P1Y2M3DT4H5M6S
157
+ def self.parse(iso_string)
158
+ raise FeelTypeError, "Duration must be a string, got #{iso_string.class}" unless iso_string.is_a?(String)
159
+ raise FeelTypeError, "Invalid duration format: must start with 'P'" unless iso_string.start_with?("P")
160
+
161
+ # Split on 'T' to separate date and time parts
162
+ parts = iso_string[1..].split("T")
163
+ date_part = parts[0] || ""
164
+ time_part = parts[1] || ""
165
+
166
+ duration_attrs = {}
167
+
168
+ # Parse date part (Y, M, D)
169
+ duration_attrs[:years] = extract_unit(date_part, "Y")
170
+ duration_attrs[:months] = extract_unit(date_part, "M")
171
+ duration_attrs[:days] = extract_unit(date_part, "D")
172
+
173
+ # Parse time part (H, M, S)
174
+ duration_attrs[:hours] = extract_unit(time_part, "H")
175
+ duration_attrs[:minutes] = extract_unit(time_part, "M")
176
+ duration_attrs[:seconds] = extract_unit(time_part, "S")
177
+
178
+ new(**duration_attrs)
179
+ end
180
+
181
+ # Convert to total seconds (approximation for date parts)
182
+ def to_seconds
183
+ total = @seconds
184
+ total += @minutes * 60
185
+ total += @hours * 3600
186
+ total += @days * 86_400
187
+ total += @months * 2_592_000 # 30 days average
188
+ total += @years * 31_536_000 # 365 days
189
+ total
190
+ end
191
+
192
+ def to_ruby
193
+ to_seconds
194
+ end
195
+
196
+ def ==(other)
197
+ return false unless other.is_a?(Duration)
198
+
199
+ @years == other.years &&
200
+ @months == other.months &&
201
+ @days == other.days &&
202
+ @hours == other.hours &&
203
+ @minutes == other.minutes &&
204
+ @seconds == other.seconds
205
+ end
206
+
207
+ def inspect
208
+ "#<Feel::Duration P#{@years}Y#{@months}M#{@days}DT#{@hours}H#{@minutes}M#{@seconds}S>"
209
+ end
210
+
211
+ def self.extract_unit(str, unit)
212
+ match = str.match(/(\d+(?:\.\d+)?)#{unit}/)
213
+ match ? match[1].to_f.to_i : 0
214
+ end
215
+ end
216
+
217
+ # Wrapper for lists (adds FEEL semantics to Ruby Array)
218
+ class List < Array
219
+ def initialize(array = [])
220
+ super
221
+ end
222
+
223
+ def to_ruby
224
+ to_a
225
+ end
226
+
227
+ def inspect
228
+ "#<Feel::List #{to_a.inspect}>"
229
+ end
230
+ end
231
+
232
+ # Wrapper for contexts (FEEL key-value maps)
233
+ class Context < Hash
234
+ def initialize(hash = {})
235
+ super()
236
+ hash.each { |k, v| self[k.to_sym] = v }
237
+ end
238
+
239
+ def to_ruby
240
+ to_h
241
+ end
242
+
243
+ def inspect
244
+ "#<Feel::Context #{to_h.inspect}>"
245
+ end
246
+ end
247
+
248
+ # Type conversion utilities
249
+ module Converter
250
+ def self.to_feel_type(value)
251
+ case value
252
+ when Integer, Float, BigDecimal
253
+ Number.new(value)
254
+ when ::Time, ::DateTime
255
+ Time.new(value)
256
+ when ::Date
257
+ Date.new(value)
258
+ when Array
259
+ List.new(value)
260
+ when Hash
261
+ Context.new(value)
262
+ else
263
+ value # Return as-is for strings, booleans, etc.
264
+ end
265
+ end
266
+
267
+ def self.to_ruby(value)
268
+ return value.to_ruby if value.respond_to?(:to_ruby)
269
+
270
+ value
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end