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,81 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"
|
|
3
|
+
xmlns:dmndi="https://www.omg.org/spec/DMN/20191111/DMNDI/"
|
|
4
|
+
xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/"
|
|
5
|
+
id="definitions_complex"
|
|
6
|
+
name="Complex Loan Decision"
|
|
7
|
+
namespace="http://decision_agent.local">
|
|
8
|
+
|
|
9
|
+
<decision id="loan_approval" name="Loan Approval Decision">
|
|
10
|
+
<description>Multi-criteria loan approval decision</description>
|
|
11
|
+
<decisionTable id="loan_approval_table" hitPolicy="FIRST">
|
|
12
|
+
<input id="input_credit_score" label="Credit Score">
|
|
13
|
+
<inputExpression typeRef="number">
|
|
14
|
+
<text>credit_score</text>
|
|
15
|
+
</inputExpression>
|
|
16
|
+
</input>
|
|
17
|
+
|
|
18
|
+
<input id="input_income" label="Annual Income">
|
|
19
|
+
<inputExpression typeRef="number">
|
|
20
|
+
<text>income</text>
|
|
21
|
+
</inputExpression>
|
|
22
|
+
</input>
|
|
23
|
+
|
|
24
|
+
<input id="input_loan_amount" label="Loan Amount">
|
|
25
|
+
<inputExpression typeRef="number">
|
|
26
|
+
<text>loan_amount</text>
|
|
27
|
+
</inputExpression>
|
|
28
|
+
</input>
|
|
29
|
+
|
|
30
|
+
<output id="output_decision" label="Decision" name="decision" typeRef="string"/>
|
|
31
|
+
|
|
32
|
+
<rule id="rule_excellent">
|
|
33
|
+
<description>Excellent credit and high income</description>
|
|
34
|
+
<inputEntry id="entry_excellent_credit">
|
|
35
|
+
<text>>= 750</text>
|
|
36
|
+
</inputEntry>
|
|
37
|
+
<inputEntry id="entry_excellent_income">
|
|
38
|
+
<text>>= 50000</text>
|
|
39
|
+
</inputEntry>
|
|
40
|
+
<inputEntry id="entry_excellent_loan">
|
|
41
|
+
<text>< 200000</text>
|
|
42
|
+
</inputEntry>
|
|
43
|
+
<outputEntry id="output_excellent">
|
|
44
|
+
<text>"approve"</text>
|
|
45
|
+
</outputEntry>
|
|
46
|
+
</rule>
|
|
47
|
+
|
|
48
|
+
<rule id="rule_good">
|
|
49
|
+
<description>Good credit and moderate income</description>
|
|
50
|
+
<inputEntry id="entry_good_credit">
|
|
51
|
+
<text>>= 650</text>
|
|
52
|
+
</inputEntry>
|
|
53
|
+
<inputEntry id="entry_good_income">
|
|
54
|
+
<text>>= 40000</text>
|
|
55
|
+
</inputEntry>
|
|
56
|
+
<inputEntry id="entry_good_loan">
|
|
57
|
+
<text>< 150000</text>
|
|
58
|
+
</inputEntry>
|
|
59
|
+
<outputEntry id="output_good">
|
|
60
|
+
<text>"conditional_approve"</text>
|
|
61
|
+
</outputEntry>
|
|
62
|
+
</rule>
|
|
63
|
+
|
|
64
|
+
<rule id="rule_default">
|
|
65
|
+
<description>Default rejection for all other cases</description>
|
|
66
|
+
<inputEntry id="entry_default_credit">
|
|
67
|
+
<text>-</text>
|
|
68
|
+
</inputEntry>
|
|
69
|
+
<inputEntry id="entry_default_income">
|
|
70
|
+
<text>-</text>
|
|
71
|
+
</inputEntry>
|
|
72
|
+
<inputEntry id="entry_default_loan">
|
|
73
|
+
<text>-</text>
|
|
74
|
+
</inputEntry>
|
|
75
|
+
<outputEntry id="output_default">
|
|
76
|
+
<text>"reject"</text>
|
|
77
|
+
</outputEntry>
|
|
78
|
+
</rule>
|
|
79
|
+
</decisionTable>
|
|
80
|
+
</decision>
|
|
81
|
+
</definitions>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"
|
|
3
|
+
id="definitions_invalid"
|
|
4
|
+
name="Invalid Decision"
|
|
5
|
+
namespace="http://decision_agent.local">
|
|
6
|
+
|
|
7
|
+
<decision id="invalid_decision" name="Invalid Decision">
|
|
8
|
+
<decisionTable id="invalid_table" hitPolicy="FIRST">
|
|
9
|
+
<input id="input1" label="Input 1">
|
|
10
|
+
<inputExpression typeRef="string">
|
|
11
|
+
<text>field1</text>
|
|
12
|
+
</inputExpression>
|
|
13
|
+
</input>
|
|
14
|
+
|
|
15
|
+
<output id="output1" label="Output 1" name="output" typeRef="string"/>
|
|
16
|
+
|
|
17
|
+
<!-- This rule is invalid: has 1 input but 2 input entries -->
|
|
18
|
+
<rule id="bad_rule">
|
|
19
|
+
<inputEntry id="entry1">
|
|
20
|
+
<text>"value1"</text>
|
|
21
|
+
</inputEntry>
|
|
22
|
+
<inputEntry id="entry2">
|
|
23
|
+
<text>"value2"</text>
|
|
24
|
+
</inputEntry>
|
|
25
|
+
<outputEntry id="output_entry">
|
|
26
|
+
<text>"result"</text>
|
|
27
|
+
</outputEntry>
|
|
28
|
+
</rule>
|
|
29
|
+
</decisionTable>
|
|
30
|
+
</decision>
|
|
31
|
+
</definitions>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"
|
|
3
|
+
xmlns:dmndi="https://www.omg.org/spec/DMN/20191111/DMNDI/"
|
|
4
|
+
xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/"
|
|
5
|
+
id="definitions_simple"
|
|
6
|
+
name="Simple Decision"
|
|
7
|
+
namespace="http://decision_agent.local">
|
|
8
|
+
|
|
9
|
+
<decision id="age_check" name="Age Check Decision">
|
|
10
|
+
<decisionTable id="age_check_table" hitPolicy="FIRST">
|
|
11
|
+
<input id="input_age" label="Age">
|
|
12
|
+
<inputExpression typeRef="number">
|
|
13
|
+
<text>age</text>
|
|
14
|
+
</inputExpression>
|
|
15
|
+
</input>
|
|
16
|
+
|
|
17
|
+
<output id="output_decision" label="Decision" name="decision" typeRef="string"/>
|
|
18
|
+
|
|
19
|
+
<rule id="rule_1">
|
|
20
|
+
<description>Approve if age >= 18</description>
|
|
21
|
+
<inputEntry id="entry_1_age">
|
|
22
|
+
<text>>= 18</text>
|
|
23
|
+
</inputEntry>
|
|
24
|
+
<outputEntry id="output_1">
|
|
25
|
+
<text>"approve"</text>
|
|
26
|
+
</outputEntry>
|
|
27
|
+
</rule>
|
|
28
|
+
|
|
29
|
+
<rule id="rule_2">
|
|
30
|
+
<description>Reject if age < 18</description>
|
|
31
|
+
<inputEntry id="entry_2_age">
|
|
32
|
+
<text>< 18</text>
|
|
33
|
+
</inputEntry>
|
|
34
|
+
<outputEntry id="output_2">
|
|
35
|
+
<text>"reject"</text>
|
|
36
|
+
</outputEntry>
|
|
37
|
+
</rule>
|
|
38
|
+
</decisionTable>
|
|
39
|
+
</decision>
|
|
40
|
+
</definitions>
|
|
@@ -3,17 +3,24 @@ require "decision_agent/monitoring/metrics_collector"
|
|
|
3
3
|
|
|
4
4
|
RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
|
|
5
5
|
let(:collector) { described_class.new(window_size: 60, storage: :memory) }
|
|
6
|
+
let(:evaluation) do
|
|
7
|
+
DecisionAgent::Evaluation.new(
|
|
8
|
+
decision: "approve",
|
|
9
|
+
weight: 0.9,
|
|
10
|
+
reason: "Test reason",
|
|
11
|
+
evaluator_name: "test_evaluator"
|
|
12
|
+
)
|
|
13
|
+
end
|
|
6
14
|
let(:decision) do
|
|
7
|
-
|
|
8
|
-
"Decision",
|
|
15
|
+
DecisionAgent::Decision.new(
|
|
9
16
|
decision: "approve",
|
|
10
17
|
confidence: 0.85,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
explanations: ["Test explanation"],
|
|
19
|
+
evaluations: [evaluation],
|
|
20
|
+
audit_payload: { timestamp: Time.now.utc.iso8601 }
|
|
14
21
|
)
|
|
15
22
|
end
|
|
16
|
-
let(:context) {
|
|
23
|
+
let(:context) { DecisionAgent::Context.new({ user: "test" }) }
|
|
17
24
|
|
|
18
25
|
describe "#initialize" do
|
|
19
26
|
it "initializes with default window size" do
|
|
@@ -67,15 +74,6 @@ RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
|
|
|
67
74
|
end
|
|
68
75
|
|
|
69
76
|
describe "#record_evaluation" do
|
|
70
|
-
let(:evaluation) do
|
|
71
|
-
double(
|
|
72
|
-
"Evaluation",
|
|
73
|
-
decision: "approve",
|
|
74
|
-
weight: 0.9,
|
|
75
|
-
evaluator_name: "test_evaluator"
|
|
76
|
-
)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
77
|
it "records an evaluation metric" do
|
|
80
78
|
metric = collector.record_evaluation(evaluation)
|
|
81
79
|
|
|
@@ -280,15 +278,6 @@ RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
|
|
|
280
278
|
end
|
|
281
279
|
|
|
282
280
|
describe "#record_evaluation" do
|
|
283
|
-
let(:evaluation) do
|
|
284
|
-
double(
|
|
285
|
-
"Evaluation",
|
|
286
|
-
decision: "approve",
|
|
287
|
-
weight: 0.9,
|
|
288
|
-
evaluator_name: "test_evaluator"
|
|
289
|
-
)
|
|
290
|
-
end
|
|
291
|
-
|
|
292
281
|
it "notifies observers" do
|
|
293
282
|
observed = []
|
|
294
283
|
collector.add_observer do |type, metric|
|
|
@@ -361,11 +350,21 @@ RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
|
|
|
361
350
|
describe "#statistics" do
|
|
362
351
|
before do
|
|
363
352
|
3.times do
|
|
364
|
-
evaluation =
|
|
353
|
+
evaluation = DecisionAgent::Evaluation.new(
|
|
354
|
+
decision: "approve",
|
|
355
|
+
weight: 0.8,
|
|
356
|
+
reason: "Test reason",
|
|
357
|
+
evaluator_name: "eval1"
|
|
358
|
+
)
|
|
365
359
|
collector.record_evaluation(evaluation)
|
|
366
360
|
end
|
|
367
361
|
2.times do
|
|
368
|
-
evaluation =
|
|
362
|
+
evaluation = DecisionAgent::Evaluation.new(
|
|
363
|
+
decision: "reject",
|
|
364
|
+
weight: 0.6,
|
|
365
|
+
reason: "Test reason",
|
|
366
|
+
evaluator_name: "eval2"
|
|
367
|
+
)
|
|
369
368
|
collector.record_evaluation(evaluation)
|
|
370
369
|
end
|
|
371
370
|
end
|
|
@@ -383,11 +382,12 @@ RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
|
|
|
383
382
|
end
|
|
384
383
|
|
|
385
384
|
it "handles decisions without duration_ms" do
|
|
386
|
-
decision_no_duration =
|
|
387
|
-
"Decision",
|
|
385
|
+
decision_no_duration = DecisionAgent::Decision.new(
|
|
388
386
|
decision: "approve",
|
|
389
387
|
confidence: 0.5,
|
|
390
|
-
|
|
388
|
+
explanations: [],
|
|
389
|
+
evaluations: [],
|
|
390
|
+
audit_payload: {}
|
|
391
391
|
)
|
|
392
392
|
collector.record_decision(decision_no_duration, context)
|
|
393
393
|
stats = collector.statistics
|
|
@@ -461,11 +461,12 @@ RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
|
|
|
461
461
|
|
|
462
462
|
describe "decision status determination" do
|
|
463
463
|
it "determines status for high confidence decisions" do
|
|
464
|
-
high_conf_decision =
|
|
465
|
-
"Decision",
|
|
464
|
+
high_conf_decision = DecisionAgent::Decision.new(
|
|
466
465
|
decision: "approve",
|
|
467
466
|
confidence: 0.9,
|
|
468
|
-
|
|
467
|
+
explanations: [],
|
|
468
|
+
evaluations: [],
|
|
469
|
+
audit_payload: {}
|
|
469
470
|
)
|
|
470
471
|
collector.record_decision(high_conf_decision, context)
|
|
471
472
|
# Just verify it records successfully
|
|
@@ -473,11 +474,12 @@ RSpec.describe DecisionAgent::Monitoring::MetricsCollector do
|
|
|
473
474
|
end
|
|
474
475
|
|
|
475
476
|
it "determines status for low confidence decisions" do
|
|
476
|
-
low_conf_decision =
|
|
477
|
-
"Decision",
|
|
477
|
+
low_conf_decision = DecisionAgent::Decision.new(
|
|
478
478
|
decision: "approve",
|
|
479
479
|
confidence: 0.2,
|
|
480
|
-
|
|
480
|
+
explanations: [],
|
|
481
|
+
evaluations: [],
|
|
482
|
+
audit_payload: {}
|
|
481
483
|
)
|
|
482
484
|
collector.record_decision(low_conf_decision, context)
|
|
483
485
|
expect(collector.metrics_count[:decisions]).to eq(1)
|
|
@@ -5,14 +5,17 @@ require "decision_agent/monitoring/monitored_agent"
|
|
|
5
5
|
RSpec.describe DecisionAgent::Monitoring::MonitoredAgent do
|
|
6
6
|
let(:collector) { DecisionAgent::Monitoring::MetricsCollector.new(storage: :memory) }
|
|
7
7
|
let(:evaluator) do
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
DecisionAgent::Evaluators::JsonRuleEvaluator.new(
|
|
9
|
+
rules_json: {
|
|
10
|
+
version: "1.0",
|
|
11
|
+
ruleset: "test",
|
|
12
|
+
rules: [{
|
|
13
|
+
id: "test_rule",
|
|
14
|
+
if: { field: "amount", op: "gt", value: 100 },
|
|
15
|
+
then: { decision: "approve", weight: 0.9, reason: "Test reason" }
|
|
16
|
+
}]
|
|
17
|
+
},
|
|
18
|
+
name: "test_evaluator"
|
|
16
19
|
)
|
|
17
20
|
end
|
|
18
21
|
let(:agent) { DecisionAgent::Agent.new(evaluators: [evaluator]) }
|
|
@@ -133,14 +136,14 @@ RSpec.describe DecisionAgent::Monitoring::MonitoredAgent do
|
|
|
133
136
|
|
|
134
137
|
it "measures decision duration accurately" do
|
|
135
138
|
# Mock agent to introduce delay
|
|
136
|
-
allow(agent).to receive(:decide) do
|
|
139
|
+
allow(agent).to receive(:decide) do |context:, **_kwargs|
|
|
137
140
|
sleep 0.01 # 10ms delay
|
|
138
|
-
evaluator.evaluate(
|
|
141
|
+
evaluation = evaluator.evaluate(context)
|
|
139
142
|
DecisionAgent::Decision.new(
|
|
140
143
|
decision: "approve",
|
|
141
144
|
confidence: 0.9,
|
|
142
145
|
explanations: ["Test"],
|
|
143
|
-
evaluations: [
|
|
146
|
+
evaluations: [evaluation].compact, # Remove nils in case evaluation returns nil
|
|
144
147
|
audit_payload: {}
|
|
145
148
|
)
|
|
146
149
|
end
|
|
@@ -431,7 +431,10 @@ RSpec.describe "Performance Optimizations" do
|
|
|
431
431
|
it "maintains high throughput with optimizations" do
|
|
432
432
|
require "benchmark"
|
|
433
433
|
|
|
434
|
-
|
|
434
|
+
# Warm up JIT and caches
|
|
435
|
+
100.times { agent.decide(context: { amount: 150, user: { verified: true }, email: "test@example.com" }) }
|
|
436
|
+
|
|
437
|
+
iterations = 2000
|
|
435
438
|
context = { amount: 150, user: { verified: true }, email: "test@example.com" }
|
|
436
439
|
|
|
437
440
|
time = Benchmark.realtime do
|
|
@@ -443,8 +446,12 @@ RSpec.describe "Performance Optimizations" do
|
|
|
443
446
|
throughput = iterations / time
|
|
444
447
|
puts "\nThroughput: #{throughput.round(2)} decisions/second"
|
|
445
448
|
|
|
446
|
-
# Should maintain at least
|
|
447
|
-
|
|
449
|
+
# Should maintain at least 2000 decisions/second
|
|
450
|
+
# Note: This test uses regex matching which is more expensive than simple comparisons.
|
|
451
|
+
# The threshold accounts for system variability, complex rules, test environment, and
|
|
452
|
+
# potential interference from other tests when running in the full suite.
|
|
453
|
+
# For simpler rules in production, expect 5,000-8,000+ decisions/second (see PERFORMANCE_AND_THREAD_SAFETY.md)
|
|
454
|
+
expect(throughput).to be > 2000
|
|
448
455
|
end
|
|
449
456
|
|
|
450
457
|
it "benefits from caching on repeated evaluations" do
|
data/spec/thread_safety_spec.rb
CHANGED
|
@@ -299,12 +299,20 @@ RSpec.describe "Thread-Safety" do
|
|
|
299
299
|
end
|
|
300
300
|
|
|
301
301
|
it "raises error for unfrozen evaluations" do
|
|
302
|
-
#
|
|
302
|
+
# NOTE: Evaluation objects are always frozen in their initializer.
|
|
303
|
+
# To test the validator's frozen check, we need to create an unfrozen instance.
|
|
304
|
+
# Using allocate allows us to bypass the initializer (which would freeze the object)
|
|
305
|
+
# and manually set instance variables to create a valid but unfrozen evaluation.
|
|
306
|
+
# This tests the edge case where an evaluation might not be frozen (though
|
|
307
|
+
# this should never happen in practice with real Evaluation instances).
|
|
303
308
|
evaluation = DecisionAgent::Evaluation.allocate
|
|
304
309
|
evaluation.instance_variable_set(:@decision, "approve")
|
|
305
310
|
evaluation.instance_variable_set(:@weight, 0.8)
|
|
306
311
|
evaluation.instance_variable_set(:@reason, "Test")
|
|
307
|
-
evaluation.instance_variable_set(:@evaluator_name, "
|
|
312
|
+
evaluation.instance_variable_set(:@evaluator_name, "TestEvaluator")
|
|
313
|
+
|
|
314
|
+
# Verify it's not frozen (this is the condition we're testing)
|
|
315
|
+
expect(evaluation).not_to be_frozen
|
|
308
316
|
|
|
309
317
|
expect do
|
|
310
318
|
DecisionAgent::EvaluationValidator.validate!(evaluation)
|