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,274 @@
1
+ require "spec_helper"
2
+ require "decision_agent/dmn/feel/simple_parser"
3
+
4
+ RSpec.describe DecisionAgent::Dmn::Feel::SimpleParser do
5
+ let(:parser) { described_class.new }
6
+
7
+ describe ".can_parse?" do
8
+ it "returns true for simple arithmetic" do
9
+ expect(described_class.can_parse?("age + 5")).to be true
10
+ expect(described_class.can_parse?("price * 1.1")).to be true
11
+ end
12
+
13
+ it "returns true for logical expressions" do
14
+ expect(described_class.can_parse?("age >= 18 and status = active")).to be true
15
+ end
16
+
17
+ it "returns false for lists" do
18
+ expect(described_class.can_parse?("[1, 2, 3]")).to be false
19
+ end
20
+
21
+ it "returns false for contexts" do
22
+ expect(described_class.can_parse?("{x: 10}")).to be false
23
+ end
24
+
25
+ it "returns false for functions" do
26
+ expect(described_class.can_parse?("sum(scores)")).to be false
27
+ end
28
+
29
+ it "returns false for quantified expressions" do
30
+ expect(described_class.can_parse?("some x in list satisfies x > 5")).to be false
31
+ end
32
+ end
33
+
34
+ describe "#parse" do
35
+ describe "literals" do
36
+ it "parses integer" do
37
+ result = parser.parse("42")
38
+ expect(result[:type]).to eq(:literal)
39
+ expect(result[:value]).to eq(42)
40
+ end
41
+
42
+ it "parses float" do
43
+ result = parser.parse("3.14")
44
+ expect(result[:type]).to eq(:literal)
45
+ expect(result[:value]).to eq(3.14)
46
+ end
47
+
48
+ it "parses negative number" do
49
+ result = parser.parse("-10")
50
+ expect(result[:type]).to eq(:literal)
51
+ expect(result[:value]).to eq(-10)
52
+ end
53
+
54
+ it "parses string" do
55
+ result = parser.parse('"hello"')
56
+ expect(result[:type]).to eq(:literal)
57
+ expect(result[:value]).to eq("hello")
58
+ end
59
+
60
+ it "parses boolean true" do
61
+ result = parser.parse("true")
62
+ expect(result[:type]).to eq(:boolean)
63
+ expect(result[:value]).to be true
64
+ end
65
+
66
+ it "parses boolean false" do
67
+ result = parser.parse("false")
68
+ expect(result[:type]).to eq(:boolean)
69
+ expect(result[:value]).to be false
70
+ end
71
+ end
72
+
73
+ describe "field references" do
74
+ it "parses field name" do
75
+ result = parser.parse("age")
76
+ expect(result[:type]).to eq(:field)
77
+ expect(result[:name]).to eq("age")
78
+ end
79
+ end
80
+
81
+ describe "arithmetic operators" do
82
+ it "parses addition" do
83
+ result = parser.parse("5 + 3")
84
+ expect(result[:type]).to eq(:arithmetic)
85
+ expect(result[:operator]).to eq("+")
86
+ expect(result[:left][:value]).to eq(5)
87
+ expect(result[:right][:value]).to eq(3)
88
+ end
89
+
90
+ it "parses subtraction" do
91
+ result = parser.parse("10 - 3")
92
+ expect(result[:type]).to eq(:arithmetic)
93
+ expect(result[:operator]).to eq("-")
94
+ end
95
+
96
+ it "parses multiplication" do
97
+ result = parser.parse("4 * 5")
98
+ expect(result[:type]).to eq(:arithmetic)
99
+ expect(result[:operator]).to eq("*")
100
+ end
101
+
102
+ it "parses division" do
103
+ result = parser.parse("20 / 4")
104
+ expect(result[:type]).to eq(:arithmetic)
105
+ expect(result[:operator]).to eq("/")
106
+ end
107
+
108
+ it "parses exponentiation" do
109
+ result = parser.parse("2 ** 3")
110
+ expect(result[:type]).to eq(:arithmetic)
111
+ expect(result[:operator]).to eq("**")
112
+ end
113
+
114
+ it "parses modulo" do
115
+ result = parser.parse("10 % 3")
116
+ expect(result[:type]).to eq(:arithmetic)
117
+ expect(result[:operator]).to eq("%")
118
+ end
119
+ end
120
+
121
+ describe "operator precedence" do
122
+ it "respects multiplication before addition" do
123
+ result = parser.parse("2 + 3 * 4")
124
+ # Should be: 2 + (3 * 4)
125
+ expect(result[:operator]).to eq("+")
126
+ expect(result[:left][:value]).to eq(2)
127
+ expect(result[:right][:operator]).to eq("*")
128
+ end
129
+
130
+ it "respects exponentiation before multiplication" do
131
+ result = parser.parse("2 * 3 ** 2")
132
+ # Should be: 2 * (3 ** 2)
133
+ expect(result[:operator]).to eq("*")
134
+ expect(result[:right][:operator]).to eq("**")
135
+ end
136
+
137
+ it "handles parentheses" do
138
+ result = parser.parse("(2 + 3) * 4")
139
+ # Should be: (2 + 3) * 4
140
+ expect(result[:operator]).to eq("*")
141
+ expect(result[:left][:operator]).to eq("+")
142
+ end
143
+ end
144
+
145
+ describe "comparison operators" do
146
+ it "parses greater than or equal" do
147
+ result = parser.parse("age >= 18")
148
+ expect(result[:type]).to eq(:comparison)
149
+ expect(result[:operator]).to eq(">=")
150
+ expect(result[:left][:name]).to eq("age")
151
+ expect(result[:right][:value]).to eq(18)
152
+ end
153
+
154
+ it "parses less than or equal" do
155
+ result = parser.parse("score <= 100")
156
+ expect(result[:type]).to eq(:comparison)
157
+ expect(result[:operator]).to eq("<=")
158
+ end
159
+
160
+ it "parses greater than" do
161
+ result = parser.parse("price > 0")
162
+ expect(result[:type]).to eq(:comparison)
163
+ expect(result[:operator]).to eq(">")
164
+ end
165
+
166
+ it "parses less than" do
167
+ result = parser.parse("age < 65")
168
+ expect(result[:type]).to eq(:comparison)
169
+ expect(result[:operator]).to eq("<")
170
+ end
171
+
172
+ it "parses not equal" do
173
+ result = parser.parse("status != pending")
174
+ expect(result[:type]).to eq(:comparison)
175
+ expect(result[:operator]).to eq("!=")
176
+ end
177
+
178
+ it "parses equal" do
179
+ result = parser.parse("status = active")
180
+ expect(result[:type]).to eq(:comparison)
181
+ expect(result[:operator]).to eq("=")
182
+ end
183
+ end
184
+
185
+ describe "logical operators" do
186
+ it "parses AND" do
187
+ result = parser.parse("age >= 18 and score > 700")
188
+ expect(result[:type]).to eq(:logical)
189
+ expect(result[:operator]).to eq("and")
190
+ expect(result[:left][:type]).to eq(:comparison)
191
+ expect(result[:right][:type]).to eq(:comparison)
192
+ end
193
+
194
+ it "parses OR" do
195
+ result = parser.parse("status = active or status = pending")
196
+ expect(result[:type]).to eq(:logical)
197
+ expect(result[:operator]).to eq("or")
198
+ end
199
+
200
+ it "parses NOT" do
201
+ result = parser.parse("not active")
202
+ expect(result[:type]).to eq(:logical)
203
+ expect(result[:operator]).to eq("not")
204
+ expect(result[:operand][:name]).to eq("active")
205
+ end
206
+
207
+ it "respects AND precedence over OR" do
208
+ result = parser.parse("a or b and c")
209
+ # Should be: a or (b and c)
210
+ expect(result[:operator]).to eq("or")
211
+ expect(result[:right][:operator]).to eq("and")
212
+ end
213
+ end
214
+
215
+ describe "unary operators" do
216
+ it "parses unary minus" do
217
+ result = parser.parse("-5")
218
+ expect(result[:type]).to eq(:literal)
219
+ expect(result[:value]).to eq(-5)
220
+ end
221
+
222
+ it "parses unary minus with field" do
223
+ result = parser.parse("-age")
224
+ expect(result[:type]).to eq(:arithmetic)
225
+ expect(result[:operator]).to eq("negate")
226
+ expect(result[:operand][:name]).to eq("age")
227
+ end
228
+
229
+ it "parses unary plus (ignored)" do
230
+ result = parser.parse("+5")
231
+ expect(result[:value]).to eq(5)
232
+ end
233
+ end
234
+
235
+ describe "complex expressions" do
236
+ it "parses arithmetic with comparison" do
237
+ result = parser.parse("age + 5 >= 18")
238
+ expect(result[:type]).to eq(:comparison)
239
+ expect(result[:left][:type]).to eq(:arithmetic)
240
+ end
241
+
242
+ it "parses multiple logical operations" do
243
+ result = parser.parse("age >= 18 and age <= 65 and status = active")
244
+ expect(result[:type]).to eq(:logical)
245
+ expect(result[:operator]).to eq("and")
246
+ end
247
+
248
+ it "parses nested parentheses" do
249
+ result = parser.parse("((age + 5) * 2) >= 40")
250
+ expect(result[:type]).to eq(:comparison)
251
+ end
252
+ end
253
+
254
+ describe "error handling" do
255
+ it "raises error for empty expression" do
256
+ expect do
257
+ parser.parse("")
258
+ end.to raise_error(DecisionAgent::Dmn::FeelParseError, /Empty expression/)
259
+ end
260
+
261
+ it "raises error for unbalanced parentheses" do
262
+ expect do
263
+ parser.parse("(age + 5")
264
+ end.to raise_error(DecisionAgent::Dmn::FeelParseError)
265
+ end
266
+
267
+ it "raises error for unexpected character" do
268
+ expect do
269
+ parser.parse("age @ 5")
270
+ end.to raise_error(DecisionAgent::Dmn::FeelParseError, /Unexpected character/)
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,176 @@
1
+ require "spec_helper"
2
+ require "decision_agent/dmn/feel/types"
3
+
4
+ RSpec.describe DecisionAgent::Dmn::Feel::Types do
5
+ describe DecisionAgent::Dmn::Feel::Types::Number do
6
+ it "creates from integer" do
7
+ num = DecisionAgent::Dmn::Feel::Types::Number.new(42)
8
+ expect(num.to_ruby).to eq(42)
9
+ expect(num.to_i).to eq(42)
10
+ end
11
+
12
+ it "creates from float" do
13
+ num = DecisionAgent::Dmn::Feel::Types::Number.new(3.14)
14
+ expect(num.to_ruby).to eq(3.14)
15
+ expect(num.to_f).to be_within(0.001).of(3.14)
16
+ end
17
+
18
+ it "creates from string" do
19
+ num = DecisionAgent::Dmn::Feel::Types::Number.new("42.5")
20
+ expect(num.to_f).to be_within(0.001).of(42.5)
21
+ end
22
+
23
+ it "supports scale tracking" do
24
+ num = DecisionAgent::Dmn::Feel::Types::Number.new(42, scale: 2)
25
+ expect(num.scale).to eq(2)
26
+ end
27
+
28
+ it "raises error for invalid type" do
29
+ expect do
30
+ DecisionAgent::Dmn::Feel::Types::Number.new([])
31
+ end.to raise_error(DecisionAgent::Dmn::FeelTypeError)
32
+ end
33
+ end
34
+
35
+ describe DecisionAgent::Dmn::Feel::Types::Date do
36
+ it "creates from Time object" do
37
+ time = Time.new(2024, 1, 15)
38
+ date = DecisionAgent::Dmn::Feel::Types::Date.new(time)
39
+ expect(date.to_ruby).to eq(time)
40
+ end
41
+
42
+ it "creates from ISO 8601 string" do
43
+ date = DecisionAgent::Dmn::Feel::Types::Date.new("2024-01-15T10:30:00Z")
44
+ expect(date.to_ruby).to be_a(Time)
45
+ end
46
+
47
+ it "creates from date string" do
48
+ date = DecisionAgent::Dmn::Feel::Types::Date.new("2024-01-15")
49
+ expect(date.to_ruby).to be_a(Time)
50
+ end
51
+
52
+ it "raises error for invalid format" do
53
+ expect do
54
+ DecisionAgent::Dmn::Feel::Types::Date.new("invalid")
55
+ end.to raise_error(DecisionAgent::Dmn::FeelTypeError)
56
+ end
57
+ end
58
+
59
+ describe DecisionAgent::Dmn::Feel::Types::Time do
60
+ it "creates from Time object" do
61
+ time = Time.new(2024, 1, 15, 10, 30, 0)
62
+ feel_time = DecisionAgent::Dmn::Feel::Types::Time.new(time)
63
+ expect(feel_time.to_ruby).to eq(time)
64
+ end
65
+
66
+ it "creates from ISO 8601 string" do
67
+ feel_time = DecisionAgent::Dmn::Feel::Types::Time.new("2024-01-15T10:30:00Z")
68
+ expect(feel_time.to_ruby).to be_a(Time)
69
+ end
70
+ end
71
+
72
+ describe DecisionAgent::Dmn::Feel::Types::Duration do
73
+ it "parses ISO 8601 duration with years" do
74
+ duration = DecisionAgent::Dmn::Feel::Types::Duration.parse("P1Y")
75
+ expect(duration.years).to eq(1)
76
+ expect(duration.months).to eq(0)
77
+ end
78
+
79
+ it "parses ISO 8601 duration with months" do
80
+ duration = DecisionAgent::Dmn::Feel::Types::Duration.parse("P3M")
81
+ expect(duration.months).to eq(3)
82
+ end
83
+
84
+ it "parses ISO 8601 duration with days" do
85
+ duration = DecisionAgent::Dmn::Feel::Types::Duration.parse("P10D")
86
+ expect(duration.days).to eq(10)
87
+ end
88
+
89
+ it "parses ISO 8601 duration with time components" do
90
+ duration = DecisionAgent::Dmn::Feel::Types::Duration.parse("PT5H30M15S")
91
+ expect(duration.hours).to eq(5)
92
+ expect(duration.minutes).to eq(30)
93
+ expect(duration.seconds).to eq(15)
94
+ end
95
+
96
+ it "parses complete ISO 8601 duration" do
97
+ duration = DecisionAgent::Dmn::Feel::Types::Duration.parse("P1Y2M3DT4H5M6S")
98
+ expect(duration.years).to eq(1)
99
+ expect(duration.months).to eq(2)
100
+ expect(duration.days).to eq(3)
101
+ expect(duration.hours).to eq(4)
102
+ expect(duration.minutes).to eq(5)
103
+ expect(duration.seconds).to eq(6)
104
+ end
105
+
106
+ it "converts to seconds" do
107
+ duration = DecisionAgent::Dmn::Feel::Types::Duration.parse("PT1H30M")
108
+ expect(duration.to_seconds).to eq(5400) # 90 minutes
109
+ end
110
+
111
+ it "raises error for invalid format" do
112
+ expect do
113
+ DecisionAgent::Dmn::Feel::Types::Duration.parse("invalid")
114
+ end.to raise_error(DecisionAgent::Dmn::FeelTypeError)
115
+ end
116
+
117
+ it "raises error for non-P prefix" do
118
+ expect do
119
+ DecisionAgent::Dmn::Feel::Types::Duration.parse("1Y2M")
120
+ end.to raise_error(DecisionAgent::Dmn::FeelTypeError, /must start with 'P'/)
121
+ end
122
+ end
123
+
124
+ describe DecisionAgent::Dmn::Feel::Types::List do
125
+ it "wraps array" do
126
+ list = DecisionAgent::Dmn::Feel::Types::List.new([1, 2, 3])
127
+ expect(list.to_ruby).to eq([1, 2, 3])
128
+ expect(list[0]).to eq(1)
129
+ expect(list.length).to eq(3)
130
+ end
131
+ end
132
+
133
+ describe DecisionAgent::Dmn::Feel::Types::Context do
134
+ it "wraps hash with symbol keys" do
135
+ ctx = DecisionAgent::Dmn::Feel::Types::Context.new({ "name" => "John", "age" => 30 })
136
+ expect(ctx[:name]).to eq("John")
137
+ expect(ctx[:age]).to eq(30)
138
+ end
139
+
140
+ it "converts string keys to symbols" do
141
+ ctx = DecisionAgent::Dmn::Feel::Types::Context.new({ "x" => 10, "y" => 20 })
142
+ expect(ctx.to_ruby).to eq({ x: 10, y: 20 })
143
+ end
144
+ end
145
+
146
+ describe DecisionAgent::Dmn::Feel::Types::Converter do
147
+ it "converts integer to Number" do
148
+ result = DecisionAgent::Dmn::Feel::Types::Converter.to_feel_type(42)
149
+ expect(result).to be_a(DecisionAgent::Dmn::Feel::Types::Number)
150
+ expect(result.to_ruby).to eq(42)
151
+ end
152
+
153
+ it "converts array to List" do
154
+ result = DecisionAgent::Dmn::Feel::Types::Converter.to_feel_type([1, 2, 3])
155
+ expect(result).to be_a(DecisionAgent::Dmn::Feel::Types::List)
156
+ expect(result.to_ruby).to eq([1, 2, 3])
157
+ end
158
+
159
+ it "converts hash to Context" do
160
+ result = DecisionAgent::Dmn::Feel::Types::Converter.to_feel_type({ x: 10 })
161
+ expect(result).to be_a(DecisionAgent::Dmn::Feel::Types::Context)
162
+ expect(result.to_ruby).to eq({ x: 10 })
163
+ end
164
+
165
+ it "converts FEEL types to Ruby" do
166
+ num = DecisionAgent::Dmn::Feel::Types::Number.new(42)
167
+ result = DecisionAgent::Dmn::Feel::Types::Converter.to_ruby(num)
168
+ expect(result).to eq(42)
169
+ end
170
+
171
+ it "returns non-FEEL types as-is" do
172
+ result = DecisionAgent::Dmn::Feel::Types::Converter.to_ruby("hello")
173
+ expect(result).to eq("hello")
174
+ end
175
+ end
176
+ end