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.
- checksums.yaml +4 -4
- data/README.md +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
- data/lib/decision_agent/dsl/schema_validator.rb +2 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +3 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1846 -1570
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- metadata +65 -1
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "decision_agent/dmn/feel/parser"
|
|
3
|
+
require "decision_agent/dmn/feel/transformer"
|
|
4
|
+
require "decision_agent/dmn/feel/evaluator"
|
|
5
|
+
|
|
6
|
+
RSpec.describe "FEEL Parser and Evaluator" do
|
|
7
|
+
let(:parser) { DecisionAgent::Dmn::Feel::Parser.new }
|
|
8
|
+
let(:transformer) { DecisionAgent::Dmn::Feel::Transformer.new }
|
|
9
|
+
let(:evaluator) { DecisionAgent::Dmn::Feel::Evaluator.new }
|
|
10
|
+
|
|
11
|
+
describe "Literals" do
|
|
12
|
+
it "parses numbers" do
|
|
13
|
+
result = parser.parse("42")
|
|
14
|
+
ast = transformer.apply(result)
|
|
15
|
+
expect(ast[:type]).to eq(:number)
|
|
16
|
+
expect(ast[:value]).to eq(42)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "parses negative numbers" do
|
|
20
|
+
result = parser.parse("-42")
|
|
21
|
+
ast = transformer.apply(result)
|
|
22
|
+
expect(ast[:type]).to eq(:number)
|
|
23
|
+
expect(ast[:value]).to eq(-42)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "parses floats" do
|
|
27
|
+
result = parser.parse("3.14")
|
|
28
|
+
ast = transformer.apply(result)
|
|
29
|
+
expect(ast[:type]).to eq(:number)
|
|
30
|
+
expect(ast[:value]).to eq(3.14)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "parses strings" do
|
|
34
|
+
result = parser.parse('"hello world"')
|
|
35
|
+
ast = transformer.apply(result)
|
|
36
|
+
expect(ast[:type]).to eq(:string)
|
|
37
|
+
expect(ast[:value]).to eq("hello world")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "parses booleans" do
|
|
41
|
+
true_result = parser.parse("true")
|
|
42
|
+
true_ast = transformer.apply(true_result)
|
|
43
|
+
expect(true_ast[:type]).to eq(:boolean)
|
|
44
|
+
expect(true_ast[:value]).to eq(true)
|
|
45
|
+
|
|
46
|
+
false_result = parser.parse("false")
|
|
47
|
+
false_ast = transformer.apply(false_result)
|
|
48
|
+
expect(false_ast[:type]).to eq(:boolean)
|
|
49
|
+
expect(false_ast[:value]).to eq(false)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "parses null" do
|
|
53
|
+
result = parser.parse("null")
|
|
54
|
+
ast = transformer.apply(result)
|
|
55
|
+
expect(ast[:type]).to eq(:null)
|
|
56
|
+
expect(ast[:value]).to be_nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe "Arithmetic Operations" do
|
|
61
|
+
let(:context) { {} }
|
|
62
|
+
|
|
63
|
+
it "evaluates addition" do
|
|
64
|
+
result = evaluator.evaluate("5 + 3", "result", context)
|
|
65
|
+
expect(result).to eq(8)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "evaluates subtraction" do
|
|
69
|
+
result = evaluator.evaluate("10 - 4", "result", context)
|
|
70
|
+
expect(result).to eq(6)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "evaluates multiplication" do
|
|
74
|
+
result = evaluator.evaluate("6 * 7", "result", context)
|
|
75
|
+
expect(result).to eq(42)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "evaluates division" do
|
|
79
|
+
result = evaluator.evaluate("20 / 4", "result", context)
|
|
80
|
+
expect(result).to eq(5.0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "evaluates exponentiation" do
|
|
84
|
+
result = evaluator.evaluate("2 ** 3", "result", context)
|
|
85
|
+
expect(result).to eq(8)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "evaluates modulo" do
|
|
89
|
+
result = evaluator.evaluate("10 % 3", "result", context)
|
|
90
|
+
expect(result).to eq(1)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "respects operator precedence" do
|
|
94
|
+
result = evaluator.evaluate("2 + 3 * 4", "result", context)
|
|
95
|
+
expect(result).to eq(14)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "evaluates parentheses" do
|
|
99
|
+
result = evaluator.evaluate("(2 + 3) * 4", "result", context)
|
|
100
|
+
expect(result).to eq(20)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe "Comparison Operations" do
|
|
105
|
+
let(:context) { {} }
|
|
106
|
+
|
|
107
|
+
it "evaluates equality" do
|
|
108
|
+
expect(evaluator.evaluate("5 = 5", "result", context)).to eq(true)
|
|
109
|
+
expect(evaluator.evaluate("5 = 3", "result", context)).to eq(false)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "evaluates inequality" do
|
|
113
|
+
expect(evaluator.evaluate("5 != 3", "result", context)).to eq(true)
|
|
114
|
+
expect(evaluator.evaluate("5 != 5", "result", context)).to eq(false)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "evaluates less than" do
|
|
118
|
+
expect(evaluator.evaluate("3 < 5", "result", context)).to eq(true)
|
|
119
|
+
expect(evaluator.evaluate("5 < 3", "result", context)).to eq(false)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "evaluates greater than" do
|
|
123
|
+
expect(evaluator.evaluate("5 > 3", "result", context)).to eq(true)
|
|
124
|
+
expect(evaluator.evaluate("3 > 5", "result", context)).to eq(false)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "evaluates less than or equal" do
|
|
128
|
+
expect(evaluator.evaluate("3 <= 5", "result", context)).to eq(true)
|
|
129
|
+
expect(evaluator.evaluate("5 <= 5", "result", context)).to eq(true)
|
|
130
|
+
expect(evaluator.evaluate("7 <= 5", "result", context)).to eq(false)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "evaluates greater than or equal" do
|
|
134
|
+
expect(evaluator.evaluate("5 >= 3", "result", context)).to eq(true)
|
|
135
|
+
expect(evaluator.evaluate("5 >= 5", "result", context)).to eq(true)
|
|
136
|
+
expect(evaluator.evaluate("3 >= 5", "result", context)).to eq(false)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe "Logical Operations" do
|
|
141
|
+
let(:context) { {} }
|
|
142
|
+
|
|
143
|
+
it "evaluates AND" do
|
|
144
|
+
expect(evaluator.evaluate("true and true", "result", context)).to eq(true)
|
|
145
|
+
expect(evaluator.evaluate("true and false", "result", context)).to eq(false)
|
|
146
|
+
expect(evaluator.evaluate("false and false", "result", context)).to eq(false)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "evaluates OR" do
|
|
150
|
+
expect(evaluator.evaluate("true or false", "result", context)).to eq(true)
|
|
151
|
+
expect(evaluator.evaluate("false or true", "result", context)).to eq(true)
|
|
152
|
+
expect(evaluator.evaluate("false or false", "result", context)).to eq(false)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "evaluates NOT" do
|
|
156
|
+
expect(evaluator.evaluate("not true", "result", context)).to eq(false)
|
|
157
|
+
expect(evaluator.evaluate("not false", "result", context)).to eq(true)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "evaluates complex logical expressions" do
|
|
161
|
+
result = evaluator.evaluate("(5 > 3) and (10 < 20)", "result", context)
|
|
162
|
+
expect(result).to eq(true)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe "Field References" do
|
|
167
|
+
it "evaluates field references" do
|
|
168
|
+
context = { age: 25 }
|
|
169
|
+
result = evaluator.evaluate("age", "result", context)
|
|
170
|
+
expect(result).to eq(25)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "evaluates field references in comparisons" do
|
|
174
|
+
context = { age: 25 }
|
|
175
|
+
result = evaluator.evaluate("age >= 18", "age", context)
|
|
176
|
+
expect(result).to eq(true)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "evaluates field references in arithmetic" do
|
|
180
|
+
context = { price: 100, quantity: 5 }
|
|
181
|
+
result = evaluator.evaluate("price * quantity", "total", context)
|
|
182
|
+
expect(result).to eq(500)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
describe "List Literals" do
|
|
187
|
+
it "parses empty lists" do
|
|
188
|
+
result = parser.parse("[]")
|
|
189
|
+
ast = transformer.apply(result)
|
|
190
|
+
expect(ast[:type]).to eq(:list_literal)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "parses lists with elements" do
|
|
194
|
+
result = parser.parse("[1, 2, 3]")
|
|
195
|
+
ast = transformer.apply(result)
|
|
196
|
+
expect(ast[:type]).to eq(:list_literal)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "evaluates list literals" do
|
|
200
|
+
context = {}
|
|
201
|
+
result = evaluator.evaluate("[1, 2, 3]", "list", context)
|
|
202
|
+
expect(result).to eq([1, 2, 3])
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
describe "Context Literals" do
|
|
207
|
+
it "parses empty contexts" do
|
|
208
|
+
result = parser.parse("{}")
|
|
209
|
+
ast = transformer.apply(result)
|
|
210
|
+
expect(ast[:type]).to eq(:context_literal)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it "parses contexts with entries" do
|
|
214
|
+
result = parser.parse('{ name: "John", age: 30 }')
|
|
215
|
+
ast = transformer.apply(result)
|
|
216
|
+
expect(ast[:type]).to eq(:context_literal)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "evaluates context literals" do
|
|
220
|
+
context = {}
|
|
221
|
+
result = evaluator.evaluate("{ a: 1, b: 2 }", "ctx", context)
|
|
222
|
+
expect(result).to eq({ a: 1, b: 2 })
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
describe "Function Calls" do
|
|
227
|
+
let(:context) { {} }
|
|
228
|
+
|
|
229
|
+
it "evaluates string length function" do
|
|
230
|
+
result = evaluator.evaluate('length("hello")', "result", context)
|
|
231
|
+
expect(result).to eq(5)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "evaluates substring function" do
|
|
235
|
+
result = evaluator.evaluate('substring("hello", 2, 3)', "result", context)
|
|
236
|
+
expect(result).to eq("ell")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "evaluates upper case function" do
|
|
240
|
+
result = evaluator.evaluate('upper("hello")', "result", context)
|
|
241
|
+
expect(result).to eq("HELLO")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "evaluates sum function" do
|
|
245
|
+
result = evaluator.evaluate("sum([1, 2, 3, 4])", "result", context)
|
|
246
|
+
expect(result).to eq(10.0)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "evaluates mean function" do
|
|
250
|
+
result = evaluator.evaluate("mean([10, 20, 30])", "result", context)
|
|
251
|
+
expect(result).to eq(20.0)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it "evaluates min function" do
|
|
255
|
+
result = evaluator.evaluate("min([5, 2, 8, 1])", "result", context)
|
|
256
|
+
expect(result).to eq(1.0)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it "evaluates max function" do
|
|
260
|
+
result = evaluator.evaluate("max([5, 2, 8, 1])", "result", context)
|
|
261
|
+
expect(result).to eq(8.0)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
describe "If-Then-Else Conditionals" do
|
|
266
|
+
let(:context) { {} }
|
|
267
|
+
|
|
268
|
+
it "evaluates true condition" do
|
|
269
|
+
result = evaluator.evaluate('if 5 > 3 then "big" else "small"', "result", context)
|
|
270
|
+
expect(result).to eq("big")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
it "evaluates false condition" do
|
|
274
|
+
result = evaluator.evaluate('if 3 > 5 then "big" else "small"', "result", context)
|
|
275
|
+
expect(result).to eq("small")
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it "evaluates with field references" do
|
|
279
|
+
context = { age: 25 }
|
|
280
|
+
result = evaluator.evaluate('if age >= 18 then "adult" else "minor"', "status", context)
|
|
281
|
+
expect(result).to eq("adult")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it "evaluates nested conditionals" do
|
|
285
|
+
context = { score: 85 }
|
|
286
|
+
result = evaluator.evaluate(
|
|
287
|
+
'if score >= 90 then "A" else if score >= 80 then "B" else "C"',
|
|
288
|
+
"grade",
|
|
289
|
+
context
|
|
290
|
+
)
|
|
291
|
+
expect(result).to eq("B")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
describe "Quantified Expressions" do
|
|
296
|
+
it "evaluates 'some' expression - true case" do
|
|
297
|
+
context = {}
|
|
298
|
+
result = evaluator.evaluate("some x in [1, 5, 10] satisfies x > 8", "result", context)
|
|
299
|
+
expect(result).to eq(true)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it "evaluates 'some' expression - false case" do
|
|
303
|
+
context = {}
|
|
304
|
+
result = evaluator.evaluate("some x in [1, 2, 3] satisfies x > 10", "result", context)
|
|
305
|
+
expect(result).to eq(false)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it "evaluates 'every' expression - true case" do
|
|
309
|
+
context = {}
|
|
310
|
+
result = evaluator.evaluate("every x in [5, 10, 15] satisfies x > 0", "result", context)
|
|
311
|
+
expect(result).to eq(true)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
it "evaluates 'every' expression - false case" do
|
|
315
|
+
context = {}
|
|
316
|
+
result = evaluator.evaluate("every x in [1, 5, 10] satisfies x > 5", "result", context)
|
|
317
|
+
expect(result).to eq(false)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
describe "For Expressions" do
|
|
322
|
+
it "evaluates for expression with arithmetic" do
|
|
323
|
+
context = {}
|
|
324
|
+
result = evaluator.evaluate("for x in [1, 2, 3] return x * 2", "result", context)
|
|
325
|
+
expect(result).to eq([2, 4, 6])
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "evaluates for expression with addition" do
|
|
329
|
+
context = {}
|
|
330
|
+
result = evaluator.evaluate("for x in [10, 20, 30] return x + 5", "result", context)
|
|
331
|
+
expect(result).to eq([15, 25, 35])
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
describe "Between Expressions" do
|
|
336
|
+
it "evaluates between - true case" do
|
|
337
|
+
context = {}
|
|
338
|
+
result = evaluator.evaluate("5 between 1 and 10", "result", context)
|
|
339
|
+
expect(result).to eq(true)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it "evaluates between - false case" do
|
|
343
|
+
context = {}
|
|
344
|
+
result = evaluator.evaluate("15 between 1 and 10", "result", context)
|
|
345
|
+
expect(result).to eq(false)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it "evaluates between with field reference" do
|
|
349
|
+
context = { age: 25 }
|
|
350
|
+
result = evaluator.evaluate("age between 18 and 65", "working_age", context)
|
|
351
|
+
expect(result).to eq(true)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
describe "Range Literals" do
|
|
356
|
+
it "parses inclusive range" do
|
|
357
|
+
result = parser.parse("[1..10]")
|
|
358
|
+
ast = transformer.apply(result)
|
|
359
|
+
expect(ast[:type]).to eq(:range)
|
|
360
|
+
expect(ast[:start_inclusive]).to eq(true)
|
|
361
|
+
expect(ast[:end_inclusive]).to eq(true)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it "parses exclusive start range" do
|
|
365
|
+
result = parser.parse("(1..10]")
|
|
366
|
+
ast = transformer.apply(result)
|
|
367
|
+
expect(ast[:type]).to eq(:range)
|
|
368
|
+
expect(ast[:start_inclusive]).to eq(false)
|
|
369
|
+
expect(ast[:end_inclusive]).to eq(true)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it "parses exclusive end range" do
|
|
373
|
+
result = parser.parse("[1..10)")
|
|
374
|
+
ast = transformer.apply(result)
|
|
375
|
+
expect(ast[:type]).to eq(:range)
|
|
376
|
+
expect(ast[:start_inclusive]).to eq(true)
|
|
377
|
+
expect(ast[:end_inclusive]).to eq(false)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
it "parses fully exclusive range" do
|
|
381
|
+
result = parser.parse("(1..10)")
|
|
382
|
+
ast = transformer.apply(result)
|
|
383
|
+
expect(ast[:type]).to eq(:range)
|
|
384
|
+
expect(ast[:start_inclusive]).to eq(false)
|
|
385
|
+
expect(ast[:end_inclusive]).to eq(false)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
describe "In Expressions" do
|
|
390
|
+
it "evaluates in with list - true case" do
|
|
391
|
+
context = {}
|
|
392
|
+
result = evaluator.evaluate("5 in [1, 3, 5, 7]", "result", context)
|
|
393
|
+
expect(result).to eq(true)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
it "evaluates in with list - false case" do
|
|
397
|
+
context = {}
|
|
398
|
+
result = evaluator.evaluate("4 in [1, 3, 5, 7]", "result", context)
|
|
399
|
+
expect(result).to eq(false)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
describe "Instance Of Expressions" do
|
|
404
|
+
it "checks number type" do
|
|
405
|
+
context = {}
|
|
406
|
+
expect(evaluator.evaluate("42 instance of number", "result", context)).to eq(true)
|
|
407
|
+
expect(evaluator.evaluate('"hello" instance of number', "result", context)).to eq(false)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
it "checks string type" do
|
|
411
|
+
context = {}
|
|
412
|
+
expect(evaluator.evaluate('"hello" instance of string', "result", context)).to eq(true)
|
|
413
|
+
expect(evaluator.evaluate("42 instance of string", "result", context)).to eq(false)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
it "checks boolean type" do
|
|
417
|
+
context = {}
|
|
418
|
+
expect(evaluator.evaluate("true instance of boolean", "result", context)).to eq(true)
|
|
419
|
+
expect(evaluator.evaluate("42 instance of boolean", "result", context)).to eq(false)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
it "checks list type" do
|
|
423
|
+
context = {}
|
|
424
|
+
expect(evaluator.evaluate("[1, 2, 3] instance of list", "result", context)).to eq(true)
|
|
425
|
+
expect(evaluator.evaluate("42 instance of list", "result", context)).to eq(false)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
it "checks context type" do
|
|
429
|
+
context = {}
|
|
430
|
+
expect(evaluator.evaluate("{a: 1} instance of context", "result", context)).to eq(true)
|
|
431
|
+
expect(evaluator.evaluate("42 instance of context", "result", context)).to eq(false)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
describe "Complex Expressions" do
|
|
436
|
+
it "evaluates complex business rule" do
|
|
437
|
+
context = {
|
|
438
|
+
age: 25,
|
|
439
|
+
income: 50_000,
|
|
440
|
+
credit_score: 720
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
expr = "if age >= 18 and income >= 30000 and credit_score >= 650 then \"approved\" else \"denied\""
|
|
444
|
+
result = evaluator.evaluate(expr, "loan_status", context)
|
|
445
|
+
expect(result).to eq("approved")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
it "evaluates nested arithmetic with comparisons" do
|
|
449
|
+
context = {
|
|
450
|
+
price: 100,
|
|
451
|
+
quantity: 5,
|
|
452
|
+
discount: 10
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
expr = "(price * quantity) - discount > 400"
|
|
456
|
+
result = evaluator.evaluate(expr, "qualifies", context)
|
|
457
|
+
expect(result).to eq(true)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
it "combines lists and functions" do
|
|
461
|
+
context = {}
|
|
462
|
+
expr = "sum([1, 2, 3]) + max([4, 5, 6])"
|
|
463
|
+
result = evaluator.evaluate(expr, "result", context)
|
|
464
|
+
expect(result).to eq(12.0)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
it "evaluates filter-like expression with quantifier" do
|
|
468
|
+
context = {}
|
|
469
|
+
expr = "some x in [10, 20, 30] satisfies x > 15"
|
|
470
|
+
result = evaluator.evaluate(expr, "has_large", context)
|
|
471
|
+
expect(result).to eq(true)
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
describe "Error Handling" do
|
|
476
|
+
it "raises error for invalid syntax" do
|
|
477
|
+
expect do
|
|
478
|
+
parser.parse("5 +")
|
|
479
|
+
end.to raise_error(DecisionAgent::Dmn::FeelParseError)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
it "falls back gracefully for unsupported expressions" do
|
|
483
|
+
context = {}
|
|
484
|
+
# Should fall back to literal equality
|
|
485
|
+
result = evaluator.evaluate("unknown_syntax", "field", context)
|
|
486
|
+
expect(result).to be_a(Hash) # Returns condition structure
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "decision_agent"
|
|
3
|
+
require "decision_agent/dmn/model"
|
|
4
|
+
require "decision_agent/evaluators/dmn_evaluator"
|
|
5
|
+
|
|
6
|
+
RSpec.describe "DMN Hit Policies" do
|
|
7
|
+
let(:model) { DecisionAgent::Dmn::Model.new(id: "test_model", name: "Test Model") }
|
|
8
|
+
|
|
9
|
+
def create_decision_table(hit_policy, rules_data)
|
|
10
|
+
table = DecisionAgent::Dmn::DecisionTable.new(id: "test_table", hit_policy: hit_policy)
|
|
11
|
+
|
|
12
|
+
# Add inputs
|
|
13
|
+
table.add_input(DecisionAgent::Dmn::Input.new(id: "input1", label: "value"))
|
|
14
|
+
table.add_output(DecisionAgent::Dmn::Output.new(id: "output1", label: "decision", name: "decision"))
|
|
15
|
+
|
|
16
|
+
# Add rules
|
|
17
|
+
rules_data.each do |rule_data|
|
|
18
|
+
rule = DecisionAgent::Dmn::Rule.new(id: rule_data[:id], description: rule_data[:description])
|
|
19
|
+
rule.add_input_entry(rule_data[:input])
|
|
20
|
+
rule.add_output_entry(rule_data[:output])
|
|
21
|
+
table.add_rule(rule)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
decision = DecisionAgent::Dmn::Decision.new(id: "test_decision", name: "Test Decision")
|
|
25
|
+
decision.decision_table = table
|
|
26
|
+
model.add_decision(decision)
|
|
27
|
+
|
|
28
|
+
table
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe "UNIQUE hit policy" do
|
|
32
|
+
it "returns result when exactly one rule matches" do
|
|
33
|
+
create_decision_table("UNIQUE", [
|
|
34
|
+
{ id: "rule1", input: ">= 10", output: '"approved"', description: "High value" },
|
|
35
|
+
{ id: "rule2", input: "< 10", output: '"rejected"', description: "Low value" }
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
39
|
+
model: model,
|
|
40
|
+
decision_id: "test_decision"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
evaluation = evaluator.evaluate(DecisionAgent::Context.new(value: 15))
|
|
44
|
+
expect(evaluation).not_to be_nil
|
|
45
|
+
expect(evaluation.decision).to eq("approved")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "raises error when no rules match" do
|
|
49
|
+
create_decision_table("UNIQUE", [
|
|
50
|
+
{ id: "rule1", input: ">= 10", output: '"approved"', description: "High value" },
|
|
51
|
+
{ id: "rule2", input: "> 20", output: '"rejected"', description: "Very high value" }
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
55
|
+
model: model,
|
|
56
|
+
decision_id: "test_decision"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Value 5 doesn't match >= 10 or > 20, so no rules match
|
|
60
|
+
expect do
|
|
61
|
+
evaluator.evaluate(DecisionAgent::Context.new(value: 5))
|
|
62
|
+
end.to raise_error(DecisionAgent::Dmn::InvalidDmnModelError, /UNIQUE hit policy requires exactly one matching rule/)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "raises error when multiple rules match" do
|
|
66
|
+
create_decision_table("UNIQUE", [
|
|
67
|
+
{ id: "rule1", input: ">= 5", output: '"approved"', description: "Rule 1" },
|
|
68
|
+
{ id: "rule2", input: ">= 10", output: '"approved"', description: "Rule 2" }
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
72
|
+
model: model,
|
|
73
|
+
decision_id: "test_decision"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect do
|
|
77
|
+
evaluator.evaluate(DecisionAgent::Context.new(value: 15))
|
|
78
|
+
end.to raise_error(DecisionAgent::Dmn::InvalidDmnModelError, /UNIQUE hit policy requires exactly one matching rule, but 2 matched/)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe "FIRST hit policy" do
|
|
83
|
+
it "returns first matching rule when multiple rules match" do
|
|
84
|
+
create_decision_table("FIRST", [
|
|
85
|
+
{ id: "rule1", input: ">= 5", output: '"first"', description: "First rule" },
|
|
86
|
+
{ id: "rule2", input: ">= 10", output: '"second"', description: "Second rule" }
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
90
|
+
model: model,
|
|
91
|
+
decision_id: "test_decision"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
evaluation = evaluator.evaluate(DecisionAgent::Context.new(value: 15))
|
|
95
|
+
expect(evaluation).not_to be_nil
|
|
96
|
+
expect(evaluation.decision).to eq("first")
|
|
97
|
+
expect(evaluation.metadata[:rule_id]).to eq("rule1")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "returns nil when no rules match" do
|
|
101
|
+
create_decision_table("FIRST", [
|
|
102
|
+
{ id: "rule1", input: ">= 10", output: '"approved"', description: "High value" }
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
106
|
+
model: model,
|
|
107
|
+
decision_id: "test_decision"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
evaluation = evaluator.evaluate(DecisionAgent::Context.new(value: 5))
|
|
111
|
+
expect(evaluation).to be_nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe "PRIORITY hit policy" do
|
|
116
|
+
it "returns first matching rule (rule order determines priority)" do
|
|
117
|
+
create_decision_table("PRIORITY", [
|
|
118
|
+
{ id: "rule1", input: ">= 5", output: '"high_priority"', description: "High priority rule" },
|
|
119
|
+
{ id: "rule2", input: ">= 10", output: '"low_priority"', description: "Low priority rule" }
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
123
|
+
model: model,
|
|
124
|
+
decision_id: "test_decision"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
evaluation = evaluator.evaluate(DecisionAgent::Context.new(value: 15))
|
|
128
|
+
expect(evaluation).not_to be_nil
|
|
129
|
+
expect(evaluation.decision).to eq("high_priority")
|
|
130
|
+
expect(evaluation.metadata[:rule_id]).to eq("rule1")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe "ANY hit policy" do
|
|
135
|
+
it "returns result when all matching rules have same output" do
|
|
136
|
+
create_decision_table("ANY", [
|
|
137
|
+
{ id: "rule1", input: ">= 5", output: '"approved"', description: "Rule 1" },
|
|
138
|
+
{ id: "rule2", input: ">= 10", output: '"approved"', description: "Rule 2" }
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
142
|
+
model: model,
|
|
143
|
+
decision_id: "test_decision"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
evaluation = evaluator.evaluate(DecisionAgent::Context.new(value: 15))
|
|
147
|
+
expect(evaluation).not_to be_nil
|
|
148
|
+
expect(evaluation.decision).to eq("approved")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "raises error when matching rules have different outputs" do
|
|
152
|
+
create_decision_table("ANY", [
|
|
153
|
+
{ id: "rule1", input: ">= 5", output: '"approved"', description: "Rule 1" },
|
|
154
|
+
{ id: "rule2", input: ">= 10", output: '"rejected"', description: "Rule 2" }
|
|
155
|
+
])
|
|
156
|
+
|
|
157
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
158
|
+
model: model,
|
|
159
|
+
decision_id: "test_decision"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
expect do
|
|
163
|
+
evaluator.evaluate(DecisionAgent::Context.new(value: 15))
|
|
164
|
+
end.to raise_error(DecisionAgent::Dmn::InvalidDmnModelError, /ANY hit policy requires all matching rules to have the same output/)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
describe "COLLECT hit policy" do
|
|
169
|
+
it "returns first match with metadata about all matches" do
|
|
170
|
+
create_decision_table("COLLECT", [
|
|
171
|
+
{ id: "rule1", input: ">= 5", output: '"match1"', description: "Rule 1" },
|
|
172
|
+
{ id: "rule2", input: ">= 10", output: '"match2"', description: "Rule 2" }
|
|
173
|
+
])
|
|
174
|
+
|
|
175
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
176
|
+
model: model,
|
|
177
|
+
decision_id: "test_decision"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
evaluation = evaluator.evaluate(DecisionAgent::Context.new(value: 15))
|
|
181
|
+
expect(evaluation).not_to be_nil
|
|
182
|
+
expect(evaluation.decision).to eq("match1")
|
|
183
|
+
expect(evaluation.metadata[:collect_count]).to eq(2)
|
|
184
|
+
expect(evaluation.metadata[:collect_decisions]).to eq(%w[match1 match2])
|
|
185
|
+
expect(evaluation.metadata[:collect_rule_ids]).to eq(%w[rule1 rule2])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "returns nil when no rules match" do
|
|
189
|
+
create_decision_table("COLLECT", [
|
|
190
|
+
{ id: "rule1", input: ">= 10", output: '"approved"', description: "High value" }
|
|
191
|
+
])
|
|
192
|
+
|
|
193
|
+
evaluator = DecisionAgent::Evaluators::DmnEvaluator.new(
|
|
194
|
+
model: model,
|
|
195
|
+
decision_id: "test_decision"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
evaluation = evaluator.evaluate(DecisionAgent::Context.new(value: 5))
|
|
199
|
+
expect(evaluation).to be_nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|