decision_agent 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +1060 -0
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/agent.rb +147 -0
- data/lib/decision_agent/audit/adapter.rb +9 -0
- data/lib/decision_agent/audit/logger_adapter.rb +27 -0
- data/lib/decision_agent/audit/null_adapter.rb +8 -0
- data/lib/decision_agent/context.rb +42 -0
- data/lib/decision_agent/decision.rb +51 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +133 -0
- data/lib/decision_agent/dsl/rule_parser.rb +36 -0
- data/lib/decision_agent/dsl/schema_validator.rb +275 -0
- data/lib/decision_agent/errors.rb +62 -0
- data/lib/decision_agent/evaluation.rb +52 -0
- data/lib/decision_agent/evaluators/base.rb +15 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +51 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +31 -0
- data/lib/decision_agent/replay/replay.rb +147 -0
- data/lib/decision_agent/scoring/base.rb +19 -0
- data/lib/decision_agent/scoring/consensus.rb +40 -0
- data/lib/decision_agent/scoring/max_weight.rb +16 -0
- data/lib/decision_agent/scoring/threshold.rb +40 -0
- data/lib/decision_agent/scoring/weighted_average.rb +26 -0
- data/lib/decision_agent/version.rb +3 -0
- data/lib/decision_agent/web/public/app.js +580 -0
- data/lib/decision_agent/web/public/index.html +190 -0
- data/lib/decision_agent/web/public/styles.css +558 -0
- data/lib/decision_agent/web/server.rb +255 -0
- data/lib/decision_agent.rb +29 -0
- data/spec/agent_spec.rb +249 -0
- data/spec/api_contract_spec.rb +430 -0
- data/spec/audit_adapters_spec.rb +74 -0
- data/spec/comprehensive_edge_cases_spec.rb +1777 -0
- data/spec/context_spec.rb +84 -0
- data/spec/dsl_validation_spec.rb +648 -0
- data/spec/edge_cases_spec.rb +353 -0
- data/spec/examples/feedback_aware_evaluator_spec.rb +460 -0
- data/spec/json_rule_evaluator_spec.rb +587 -0
- data/spec/replay_edge_cases_spec.rb +699 -0
- data/spec/replay_spec.rb +210 -0
- data/spec/scoring_spec.rb +225 -0
- data/spec/spec_helper.rb +28 -0
- metadata +133 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Edge Cases" do
|
|
4
|
+
describe "missing fields in context" do
|
|
5
|
+
it "handles missing fields gracefully in rule evaluation" do
|
|
6
|
+
rules = {
|
|
7
|
+
version: "1.0",
|
|
8
|
+
ruleset: "test",
|
|
9
|
+
rules: [
|
|
10
|
+
{
|
|
11
|
+
id: "rule_1",
|
|
12
|
+
if: { field: "missing_field", op: "eq", value: "value" },
|
|
13
|
+
then: { decision: "approve" }
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
19
|
+
context = DecisionAgent::Context.new({})
|
|
20
|
+
|
|
21
|
+
evaluation = evaluator.evaluate(context)
|
|
22
|
+
|
|
23
|
+
expect(evaluation).to be_nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "handles nil values in comparisons" do
|
|
27
|
+
rules = {
|
|
28
|
+
version: "1.0",
|
|
29
|
+
ruleset: "test",
|
|
30
|
+
rules: [
|
|
31
|
+
{
|
|
32
|
+
id: "rule_1",
|
|
33
|
+
if: { field: "value", op: "gt", value: 10 },
|
|
34
|
+
then: { decision: "approve" }
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
40
|
+
context = DecisionAgent::Context.new({ value: nil })
|
|
41
|
+
|
|
42
|
+
evaluation = evaluator.evaluate(context)
|
|
43
|
+
|
|
44
|
+
expect(evaluation).to be_nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe "confidence edge cases" do
|
|
49
|
+
it "raises error when confidence exceeds 1.0" do
|
|
50
|
+
expect {
|
|
51
|
+
DecisionAgent::Decision.new(
|
|
52
|
+
decision: "test",
|
|
53
|
+
confidence: 1.5,
|
|
54
|
+
explanations: [],
|
|
55
|
+
evaluations: [],
|
|
56
|
+
audit_payload: {}
|
|
57
|
+
)
|
|
58
|
+
}.to raise_error(DecisionAgent::InvalidConfidenceError)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "raises error when confidence is negative" do
|
|
62
|
+
expect {
|
|
63
|
+
DecisionAgent::Decision.new(
|
|
64
|
+
decision: "test",
|
|
65
|
+
confidence: -0.1,
|
|
66
|
+
explanations: [],
|
|
67
|
+
evaluations: [],
|
|
68
|
+
audit_payload: {}
|
|
69
|
+
)
|
|
70
|
+
}.to raise_error(DecisionAgent::InvalidConfidenceError)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "accepts confidence at boundary values" do
|
|
74
|
+
decision0 = DecisionAgent::Decision.new(
|
|
75
|
+
decision: "test",
|
|
76
|
+
confidence: 0.0,
|
|
77
|
+
explanations: [],
|
|
78
|
+
evaluations: [],
|
|
79
|
+
audit_payload: {}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
decision1 = DecisionAgent::Decision.new(
|
|
83
|
+
decision: "test",
|
|
84
|
+
confidence: 1.0,
|
|
85
|
+
explanations: [],
|
|
86
|
+
evaluations: [],
|
|
87
|
+
audit_payload: {}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(decision0.confidence).to eq(0.0)
|
|
91
|
+
expect(decision1.confidence).to eq(1.0)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe "weight edge cases" do
|
|
96
|
+
it "raises error when weight exceeds 1.0" do
|
|
97
|
+
expect {
|
|
98
|
+
DecisionAgent::Evaluation.new(
|
|
99
|
+
decision: "test",
|
|
100
|
+
weight: 1.5,
|
|
101
|
+
reason: "test",
|
|
102
|
+
evaluator_name: "test"
|
|
103
|
+
)
|
|
104
|
+
}.to raise_error(DecisionAgent::InvalidWeightError)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "raises error when weight is negative" do
|
|
108
|
+
expect {
|
|
109
|
+
DecisionAgent::Evaluation.new(
|
|
110
|
+
decision: "test",
|
|
111
|
+
weight: -0.1,
|
|
112
|
+
reason: "test",
|
|
113
|
+
evaluator_name: "test"
|
|
114
|
+
)
|
|
115
|
+
}.to raise_error(DecisionAgent::InvalidWeightError)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "accepts weight at boundary values" do
|
|
119
|
+
eval0 = DecisionAgent::Evaluation.new(
|
|
120
|
+
decision: "test",
|
|
121
|
+
weight: 0.0,
|
|
122
|
+
reason: "test",
|
|
123
|
+
evaluator_name: "test"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
eval1 = DecisionAgent::Evaluation.new(
|
|
127
|
+
decision: "test",
|
|
128
|
+
weight: 1.0,
|
|
129
|
+
reason: "test",
|
|
130
|
+
evaluator_name: "test"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
expect(eval0.weight).to eq(0.0)
|
|
134
|
+
expect(eval1.weight).to eq(1.0)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
describe "empty arrays and collections" do
|
|
139
|
+
it "handles rules with empty 'all' conditions" do
|
|
140
|
+
rules = {
|
|
141
|
+
version: "1.0",
|
|
142
|
+
ruleset: "test",
|
|
143
|
+
rules: [
|
|
144
|
+
{
|
|
145
|
+
id: "rule_1",
|
|
146
|
+
if: { all: [] },
|
|
147
|
+
then: { decision: "approve" }
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
153
|
+
context = DecisionAgent::Context.new({})
|
|
154
|
+
|
|
155
|
+
evaluation = evaluator.evaluate(context)
|
|
156
|
+
|
|
157
|
+
expect(evaluation).not_to be_nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "handles rules with empty 'any' conditions" do
|
|
161
|
+
rules = {
|
|
162
|
+
version: "1.0",
|
|
163
|
+
ruleset: "test",
|
|
164
|
+
rules: [
|
|
165
|
+
{
|
|
166
|
+
id: "rule_1",
|
|
167
|
+
if: { any: [] },
|
|
168
|
+
then: { decision: "approve" }
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
174
|
+
context = DecisionAgent::Context.new({})
|
|
175
|
+
|
|
176
|
+
evaluation = evaluator.evaluate(context)
|
|
177
|
+
|
|
178
|
+
expect(evaluation).to be_nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
describe "type mismatches in comparisons" do
|
|
183
|
+
it "handles type mismatches in numeric comparisons" do
|
|
184
|
+
rules = {
|
|
185
|
+
version: "1.0",
|
|
186
|
+
ruleset: "test",
|
|
187
|
+
rules: [
|
|
188
|
+
{
|
|
189
|
+
id: "rule_1",
|
|
190
|
+
if: { field: "value", op: "gt", value: 10 },
|
|
191
|
+
then: { decision: "approve" }
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
197
|
+
context = DecisionAgent::Context.new({ value: "not_a_number" })
|
|
198
|
+
|
|
199
|
+
evaluation = evaluator.evaluate(context)
|
|
200
|
+
|
|
201
|
+
expect(evaluation).to be_nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
describe "immutability" do
|
|
206
|
+
it "freezes context data to prevent modification" do
|
|
207
|
+
context = DecisionAgent::Context.new({ user: "alice" })
|
|
208
|
+
|
|
209
|
+
expect {
|
|
210
|
+
context.to_h[:user] = "bob"
|
|
211
|
+
}.to raise_error(FrozenError)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it "freezes evaluation fields" do
|
|
215
|
+
evaluation = DecisionAgent::Evaluation.new(
|
|
216
|
+
decision: "approve",
|
|
217
|
+
weight: 0.8,
|
|
218
|
+
reason: "test",
|
|
219
|
+
evaluator_name: "test"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(evaluation.decision).to be_frozen
|
|
223
|
+
expect(evaluation.reason).to be_frozen
|
|
224
|
+
expect(evaluation.evaluator_name).to be_frozen
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it "freezes decision fields" do
|
|
228
|
+
decision = DecisionAgent::Decision.new(
|
|
229
|
+
decision: "approve",
|
|
230
|
+
confidence: 0.8,
|
|
231
|
+
explanations: ["test"],
|
|
232
|
+
evaluations: [],
|
|
233
|
+
audit_payload: {}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
expect(decision.decision).to be_frozen
|
|
237
|
+
expect(decision.explanations).to be_frozen
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
describe "special characters and unicode" do
|
|
242
|
+
it "handles unicode in context values" do
|
|
243
|
+
context = DecisionAgent::Context.new({ user: "用户", message: "Hello 世界" })
|
|
244
|
+
|
|
245
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
246
|
+
agent = DecisionAgent::Agent.new(evaluators: [evaluator])
|
|
247
|
+
|
|
248
|
+
result = agent.decide(context: context)
|
|
249
|
+
|
|
250
|
+
expect(result.audit_payload[:context][:user]).to eq("用户")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "handles special characters in rule values" do
|
|
254
|
+
rules = {
|
|
255
|
+
version: "1.0",
|
|
256
|
+
ruleset: "test",
|
|
257
|
+
rules: [
|
|
258
|
+
{
|
|
259
|
+
id: "rule_1",
|
|
260
|
+
if: { field: "symbol", op: "eq", value: "@#$%^&*()" },
|
|
261
|
+
then: { decision: "special" }
|
|
262
|
+
}
|
|
263
|
+
]
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
267
|
+
context = DecisionAgent::Context.new({ symbol: "@#$%^&*()" })
|
|
268
|
+
|
|
269
|
+
evaluation = evaluator.evaluate(context)
|
|
270
|
+
|
|
271
|
+
expect(evaluation).not_to be_nil
|
|
272
|
+
expect(evaluation.decision).to eq("special")
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
describe "very large numbers and values" do
|
|
277
|
+
it "handles large numeric values in comparisons" do
|
|
278
|
+
rules = {
|
|
279
|
+
version: "1.0",
|
|
280
|
+
ruleset: "test",
|
|
281
|
+
rules: [
|
|
282
|
+
{
|
|
283
|
+
id: "rule_1",
|
|
284
|
+
if: { field: "amount", op: "gte", value: 1_000_000_000 },
|
|
285
|
+
then: { decision: "large_amount" }
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
291
|
+
context = DecisionAgent::Context.new({ amount: 5_000_000_000 })
|
|
292
|
+
|
|
293
|
+
evaluation = evaluator.evaluate(context)
|
|
294
|
+
|
|
295
|
+
expect(evaluation).not_to be_nil
|
|
296
|
+
expect(evaluation.decision).to eq("large_amount")
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
describe "deeply nested context" do
|
|
301
|
+
it "handles deeply nested field access" do
|
|
302
|
+
rules = {
|
|
303
|
+
version: "1.0",
|
|
304
|
+
ruleset: "test",
|
|
305
|
+
rules: [
|
|
306
|
+
{
|
|
307
|
+
id: "rule_1",
|
|
308
|
+
if: { field: "a.b.c.d.e", op: "eq", value: "deep" },
|
|
309
|
+
then: { decision: "found_deep" }
|
|
310
|
+
}
|
|
311
|
+
]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
evaluator = DecisionAgent::Evaluators::JsonRuleEvaluator.new(rules_json: rules)
|
|
315
|
+
context = DecisionAgent::Context.new({
|
|
316
|
+
a: {
|
|
317
|
+
b: {
|
|
318
|
+
c: {
|
|
319
|
+
d: {
|
|
320
|
+
e: "deep"
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
evaluation = evaluator.evaluate(context)
|
|
328
|
+
|
|
329
|
+
expect(evaluation).not_to be_nil
|
|
330
|
+
expect(evaluation.decision).to eq("found_deep")
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
describe "audit adapter errors" do
|
|
335
|
+
it "propagates errors from audit adapter" do
|
|
336
|
+
failing_adapter = Class.new(DecisionAgent::Audit::Adapter) do
|
|
337
|
+
def record(decision, context)
|
|
338
|
+
raise StandardError, "Audit failed"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
evaluator = DecisionAgent::Evaluators::StaticEvaluator.new(decision: "approve")
|
|
343
|
+
agent = DecisionAgent::Agent.new(
|
|
344
|
+
evaluators: [evaluator],
|
|
345
|
+
audit_adapter: failing_adapter.new
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
expect {
|
|
349
|
+
agent.decide(context: {})
|
|
350
|
+
}.to raise_error(StandardError, "Audit failed")
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|