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
@@ -547,4 +547,232 @@ RSpec.describe DecisionAgent::Auth::RbacAdapter do
547
547
  end
548
548
  end
549
549
  end
550
+
551
+ describe "Integration tests with real User and Role objects" do
552
+ let(:adapter) { DecisionAgent::Auth::DefaultAdapter.new }
553
+
554
+ describe "with real User and Role objects" do
555
+ it "checks permissions using real Role class" do
556
+ user = DecisionAgent::Auth::User.new(
557
+ id: "user1",
558
+ email: "admin@example.com",
559
+ password: "password123",
560
+ roles: [:admin]
561
+ )
562
+
563
+ expect(adapter.can?(user, :read)).to be true
564
+ expect(adapter.can?(user, :write)).to be true
565
+ expect(adapter.can?(user, :delete)).to be true
566
+ expect(adapter.can?(user, :manage_users)).to be true
567
+ expect(adapter.can?(user, :audit)).to be true
568
+ end
569
+
570
+ it "checks permissions for editor role" do
571
+ user = DecisionAgent::Auth::User.new(
572
+ id: "user2",
573
+ email: "editor@example.com",
574
+ password: "password123",
575
+ roles: [:editor]
576
+ )
577
+
578
+ expect(adapter.can?(user, :read)).to be true
579
+ expect(adapter.can?(user, :write)).to be true
580
+ expect(adapter.can?(user, :delete)).to be false
581
+ expect(adapter.can?(user, :manage_users)).to be false
582
+ expect(adapter.can?(user, :audit)).to be false
583
+ end
584
+
585
+ it "checks permissions for viewer role" do
586
+ user = DecisionAgent::Auth::User.new(
587
+ id: "user3",
588
+ email: "viewer@example.com",
589
+ password: "password123",
590
+ roles: [:viewer]
591
+ )
592
+
593
+ expect(adapter.can?(user, :read)).to be true
594
+ expect(adapter.can?(user, :write)).to be false
595
+ expect(adapter.can?(user, :delete)).to be false
596
+ expect(adapter.can?(user, :manage_users)).to be false
597
+ end
598
+
599
+ it "checks permissions for approver role" do
600
+ user = DecisionAgent::Auth::User.new(
601
+ id: "user4",
602
+ email: "approver@example.com",
603
+ password: "password123",
604
+ roles: [:approver]
605
+ )
606
+
607
+ expect(adapter.can?(user, :read)).to be true
608
+ expect(adapter.can?(user, :approve)).to be true
609
+ expect(adapter.can?(user, :write)).to be false
610
+ expect(adapter.can?(user, :delete)).to be false
611
+ end
612
+
613
+ it "checks permissions for auditor role" do
614
+ user = DecisionAgent::Auth::User.new(
615
+ id: "user5",
616
+ email: "auditor@example.com",
617
+ password: "password123",
618
+ roles: [:auditor]
619
+ )
620
+
621
+ expect(adapter.can?(user, :read)).to be true
622
+ expect(adapter.can?(user, :audit)).to be true
623
+ expect(adapter.can?(user, :write)).to be false
624
+ expect(adapter.can?(user, :delete)).to be false
625
+ end
626
+
627
+ it "checks permissions for user with multiple roles" do
628
+ user = DecisionAgent::Auth::User.new(
629
+ id: "user6",
630
+ email: "multi@example.com",
631
+ password: "password123",
632
+ roles: %i[viewer editor]
633
+ )
634
+
635
+ # Should have permissions from both roles
636
+ expect(adapter.can?(user, :read)).to be true # from viewer
637
+ expect(adapter.can?(user, :write)).to be true # from editor
638
+ expect(adapter.can?(user, :delete)).to be false
639
+ end
640
+
641
+ it "checks role membership using real Role class" do
642
+ admin_user = DecisionAgent::Auth::User.new(
643
+ id: "admin1",
644
+ email: "admin@example.com",
645
+ password: "password123",
646
+ roles: [:admin]
647
+ )
648
+
649
+ expect(adapter.has_role?(admin_user, :admin)).to be true
650
+ expect(adapter.has_role?(admin_user, :editor)).to be false
651
+ expect(adapter.has_role?(admin_user, :viewer)).to be false
652
+
653
+ editor_user = DecisionAgent::Auth::User.new(
654
+ id: "editor1",
655
+ email: "editor@example.com",
656
+ password: "password123",
657
+ roles: [:editor]
658
+ )
659
+
660
+ expect(adapter.has_role?(editor_user, :editor)).to be true
661
+ expect(adapter.has_role?(editor_user, :admin)).to be false
662
+ end
663
+
664
+ it "checks role membership for users with multiple roles" do
665
+ user = DecisionAgent::Auth::User.new(
666
+ id: "multi1",
667
+ email: "multi@example.com",
668
+ password: "password123",
669
+ roles: %i[admin editor]
670
+ )
671
+
672
+ expect(adapter.has_role?(user, :admin)).to be true
673
+ expect(adapter.has_role?(user, :editor)).to be true
674
+ expect(adapter.has_role?(user, :viewer)).to be false
675
+ end
676
+
677
+ it "handles dynamic role assignment" do
678
+ user = DecisionAgent::Auth::User.new(
679
+ id: "dynamic1",
680
+ email: "dynamic@example.com",
681
+ password: "password123",
682
+ roles: []
683
+ )
684
+
685
+ expect(adapter.has_role?(user, :admin)).to be false
686
+ expect(adapter.can?(user, :read)).to be false
687
+
688
+ # Assign role dynamically
689
+ user.assign_role(:viewer)
690
+
691
+ expect(adapter.has_role?(user, :viewer)).to be true
692
+ expect(adapter.can?(user, :read)).to be true
693
+ expect(adapter.can?(user, :write)).to be false
694
+ end
695
+
696
+ it "handles role removal" do
697
+ user = DecisionAgent::Auth::User.new(
698
+ id: "remove1",
699
+ email: "remove@example.com",
700
+ password: "password123",
701
+ roles: %i[admin editor]
702
+ )
703
+
704
+ expect(adapter.has_role?(user, :admin)).to be true
705
+ expect(adapter.can?(user, :manage_users)).to be true
706
+
707
+ # Remove admin role
708
+ user.remove_role(:admin)
709
+
710
+ expect(adapter.has_role?(user, :admin)).to be false
711
+ expect(adapter.has_role?(user, :editor)).to be true
712
+ expect(adapter.can?(user, :manage_users)).to be false
713
+ expect(adapter.can?(user, :write)).to be true # Still has editor role
714
+ end
715
+
716
+ it "checks active status with real User objects" do
717
+ active_user = DecisionAgent::Auth::User.new(
718
+ id: "active1",
719
+ email: "active@example.com",
720
+ password: "password123",
721
+ roles: [:admin],
722
+ active: true
723
+ )
724
+
725
+ expect(adapter.active?(active_user)).to be true
726
+ expect(adapter.can?(active_user, :read)).to be true
727
+
728
+ inactive_user = DecisionAgent::Auth::User.new(
729
+ id: "inactive1",
730
+ email: "inactive@example.com",
731
+ password: "password123",
732
+ roles: [:admin],
733
+ active: false
734
+ )
735
+
736
+ expect(adapter.active?(inactive_user)).to be false
737
+ expect(adapter.can?(inactive_user, :read)).to be false # Inactive users can't do anything
738
+ end
739
+
740
+ it "gets user ID and email from real User objects" do
741
+ user = DecisionAgent::Auth::User.new(
742
+ id: "userid123",
743
+ email: "real@example.com",
744
+ password: "password123",
745
+ roles: [:admin]
746
+ )
747
+
748
+ expect(adapter.user_id(user)).to eq("userid123")
749
+ expect(adapter.user_email(user)).to eq("real@example.com")
750
+ end
751
+
752
+ it "verifies permission checking integrates with real Role.has_permission? method" do
753
+ # Test that the adapter correctly uses Role.has_permission? for all defined roles
754
+ roles_to_test = DecisionAgent::Auth::Role.all
755
+
756
+ roles_to_test.each do |role|
757
+ user = DecisionAgent::Auth::User.new(
758
+ id: "role_test_#{role}",
759
+ email: "#{role}@example.com",
760
+ password: "password123",
761
+ roles: [role]
762
+ )
763
+
764
+ # Get expected permissions from Role class
765
+ expected_permissions = DecisionAgent::Auth::Role.permissions_for(role)
766
+
767
+ # Verify adapter returns same permissions
768
+ all_permissions = %i[read write delete approve deploy manage_users audit]
769
+ all_permissions.each do |permission|
770
+ expected = expected_permissions.include?(permission)
771
+ actual = adapter.can?(user, permission)
772
+ expect(actual).to eq(expected), "Role #{role} permission #{permission} mismatch: expected #{expected}, got #{actual}"
773
+ end
774
+ end
775
+ end
776
+ end
777
+ end
550
778
  end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "decision_agent/dmn/decision_graph"
5
+
6
+ RSpec.describe DecisionAgent::Dmn::DecisionGraph do
7
+ describe "graph construction" do
8
+ let(:graph) do
9
+ described_class.new(id: "graph1", name: "Test Graph")
10
+ end
11
+
12
+ it "creates an empty graph" do
13
+ expect(graph.decisions).to be_empty
14
+ end
15
+
16
+ it "adds decisions to the graph" do
17
+ decision = DecisionAgent::Dmn::DecisionNode.new(
18
+ id: "decision1",
19
+ name: "First Decision"
20
+ )
21
+ graph.add_decision(decision)
22
+
23
+ expect(graph.decisions["decision1"]).to eq(decision)
24
+ end
25
+
26
+ it "retrieves decisions by id" do
27
+ decision = DecisionAgent::Dmn::DecisionNode.new(id: "decision1", name: "Test")
28
+ graph.add_decision(decision)
29
+
30
+ retrieved = graph.get_decision("decision1")
31
+ expect(retrieved).to eq(decision)
32
+ end
33
+ end
34
+
35
+ describe "decision dependencies" do
36
+ let(:graph) do
37
+ graph = described_class.new(id: "dep_graph", name: "Dependency Graph")
38
+
39
+ # Create decisions
40
+ decision1 = DecisionAgent::Dmn::DecisionNode.new(
41
+ id: "base_rate",
42
+ name: "Base Rate",
43
+ decision_logic: 0.05
44
+ )
45
+
46
+ decision2 = DecisionAgent::Dmn::DecisionNode.new(
47
+ id: "risk_adjustment",
48
+ name: "Risk Adjustment",
49
+ decision_logic: 0.02
50
+ )
51
+
52
+ decision3 = DecisionAgent::Dmn::DecisionNode.new(
53
+ id: "final_rate",
54
+ name: "Final Rate",
55
+ decision_logic: ->(context) { context["base_rate"] + context["risk_adjustment"] }
56
+ )
57
+
58
+ # Add dependencies
59
+ decision3.add_dependency("base_rate")
60
+ decision3.add_dependency("risk_adjustment")
61
+
62
+ graph.add_decision(decision1)
63
+ graph.add_decision(decision2)
64
+ graph.add_decision(decision3)
65
+
66
+ graph
67
+ end
68
+
69
+ it "tracks decision dependencies" do
70
+ decision = graph.get_decision("final_rate")
71
+ expect(decision.information_requirements.length).to eq(2)
72
+ expect(decision.depends_on?("base_rate")).to be true
73
+ expect(decision.depends_on?("risk_adjustment")).to be true
74
+ end
75
+
76
+ it "evaluates decision with dependencies" do
77
+ result = graph.evaluate("final_rate", {})
78
+ expect(result).to eq(0.07) # 0.05 + 0.02
79
+ end
80
+ end
81
+
82
+ describe "topological ordering" do
83
+ let(:graph) do
84
+ graph = described_class.new(id: "topo_graph", name: "Topological Graph")
85
+
86
+ # Create a dependency chain: A -> B -> C
87
+ decision_a = DecisionAgent::Dmn::DecisionNode.new(id: "a", name: "A", decision_logic: 1)
88
+ decision_b = DecisionAgent::Dmn::DecisionNode.new(id: "b", name: "B", decision_logic: ->(ctx) { ctx["a"] + 1 })
89
+ decision_c = DecisionAgent::Dmn::DecisionNode.new(id: "c", name: "C", decision_logic: ->(ctx) { ctx["b"] + 1 })
90
+
91
+ decision_b.add_dependency("a")
92
+ decision_c.add_dependency("b")
93
+
94
+ graph.add_decision(decision_a)
95
+ graph.add_decision(decision_b)
96
+ graph.add_decision(decision_c)
97
+
98
+ graph
99
+ end
100
+
101
+ it "returns decisions in topological order" do
102
+ order = graph.topological_order
103
+ expect(order.index("a")).to be < order.index("b")
104
+ expect(order.index("b")).to be < order.index("c")
105
+ end
106
+
107
+ it "identifies root decisions" do
108
+ roots = graph.root_decisions
109
+ expect(roots).to eq(["a"])
110
+ end
111
+
112
+ it "identifies leaf decisions" do
113
+ leaves = graph.leaf_decisions
114
+ expect(leaves).to eq(["c"])
115
+ end
116
+ end
117
+
118
+ describe "circular dependency detection" do
119
+ it "detects circular dependencies" do
120
+ graph = described_class.new(id: "circular", name: "Circular Graph")
121
+
122
+ decision_a = DecisionAgent::Dmn::DecisionNode.new(id: "a", name: "A")
123
+ decision_b = DecisionAgent::Dmn::DecisionNode.new(id: "b", name: "B")
124
+
125
+ decision_a.add_dependency("b")
126
+ decision_b.add_dependency("a")
127
+
128
+ graph.add_decision(decision_a)
129
+ graph.add_decision(decision_b)
130
+
131
+ expect(graph.circular_dependencies?).to be true
132
+ end
133
+
134
+ it "raises error when evaluating circular dependencies" do
135
+ graph = described_class.new(id: "circular", name: "Circular Graph")
136
+
137
+ decision_a = DecisionAgent::Dmn::DecisionNode.new(id: "a", name: "A")
138
+ decision_b = DecisionAgent::Dmn::DecisionNode.new(id: "b", name: "B")
139
+
140
+ decision_a.add_dependency("b")
141
+ decision_b.add_dependency("a")
142
+
143
+ graph.add_decision(decision_a)
144
+ graph.add_decision(decision_b)
145
+
146
+ expect { graph.topological_order }.to raise_error(DecisionAgent::Dmn::DmnError, /Circular dependency/)
147
+ end
148
+ end
149
+
150
+ describe "complex graph evaluation" do
151
+ let(:graph) do
152
+ # Build a loan approval graph
153
+ # Decisions: income_check -> credit_check -> final_decision
154
+ graph = described_class.new(id: "loan_graph", name: "Loan Approval Graph")
155
+
156
+ income_check = DecisionAgent::Dmn::DecisionNode.new(
157
+ id: "income_check",
158
+ name: "Income Check",
159
+ decision_logic: ->(ctx) { ctx["income"] >= 50_000 ? "sufficient" : "insufficient" }
160
+ )
161
+
162
+ credit_check = DecisionAgent::Dmn::DecisionNode.new(
163
+ id: "credit_check",
164
+ name: "Credit Check",
165
+ decision_logic: ->(ctx) { ctx["credit_score"] >= 650 ? "good" : "poor" }
166
+ )
167
+
168
+ final_decision = DecisionAgent::Dmn::DecisionNode.new(
169
+ id: "final_decision",
170
+ name: "Final Decision",
171
+ decision_logic: lambda do |ctx|
172
+ if ctx["income_check"] == "sufficient" && ctx["credit_check"] == "good"
173
+ "Approved"
174
+ else
175
+ "Rejected"
176
+ end
177
+ end
178
+ )
179
+
180
+ final_decision.add_dependency("income_check", "income_check")
181
+ final_decision.add_dependency("credit_check", "credit_check")
182
+
183
+ graph.add_decision(income_check)
184
+ graph.add_decision(credit_check)
185
+ graph.add_decision(final_decision)
186
+
187
+ graph
188
+ end
189
+
190
+ it "evaluates graph with all dependencies for approved case" do
191
+ context = { income: 60_000, credit_score: 700 }
192
+ result = graph.evaluate("final_decision", context)
193
+ expect(result).to eq("Approved")
194
+ end
195
+
196
+ it "evaluates graph with all dependencies for rejected case" do
197
+ context = { income: 40_000, credit_score: 600 }
198
+ result = graph.evaluate("final_decision", context)
199
+ expect(result).to eq("Rejected")
200
+ end
201
+
202
+ it "evaluates all decisions in graph" do
203
+ context = { income: 60_000, credit_score: 700 }
204
+ results = graph.evaluate_all(context)
205
+
206
+ expect(results["income_check"]).to eq("sufficient")
207
+ expect(results["credit_check"]).to eq("good")
208
+ expect(results["final_decision"]).to eq("Approved")
209
+ end
210
+ end
211
+
212
+ describe "graph analysis" do
213
+ let(:graph) do
214
+ graph = described_class.new(id: "analysis", name: "Analysis Graph")
215
+
216
+ d1 = DecisionAgent::Dmn::DecisionNode.new(id: "d1", name: "D1", decision_logic: 1)
217
+ d2 = DecisionAgent::Dmn::DecisionNode.new(id: "d2", name: "D2", decision_logic: 2)
218
+ d3 = DecisionAgent::Dmn::DecisionNode.new(id: "d3", name: "D3", decision_logic: 3)
219
+
220
+ d3.add_dependency("d1")
221
+ d3.add_dependency("d2")
222
+
223
+ graph.add_decision(d1)
224
+ graph.add_decision(d2)
225
+ graph.add_decision(d3)
226
+
227
+ graph
228
+ end
229
+
230
+ it "exports dependency graph structure" do
231
+ dep_graph = graph.dependency_graph
232
+ expect(dep_graph["d1"]).to eq([])
233
+ expect(dep_graph["d2"]).to eq([])
234
+ expect(dep_graph["d3"]).to contain_exactly("d1", "d2")
235
+ end
236
+
237
+ it "exports graph to hash representation" do
238
+ hash = graph.to_h
239
+ expect(hash[:id]).to eq("analysis")
240
+ expect(hash[:name]).to eq("Analysis Graph")
241
+ expect(hash[:decisions].keys).to contain_exactly("d1", "d2", "d3")
242
+ expect(hash[:dependency_graph]).to be_a(Hash)
243
+ end
244
+ end
245
+
246
+ describe DecisionAgent::Dmn::DecisionNode do
247
+ it "creates decision node with basic attributes" do
248
+ node = described_class.new(id: "test", name: "Test Decision")
249
+ expect(node.id).to eq("test")
250
+ expect(node.name).to eq("Test Decision")
251
+ expect(node.information_requirements).to be_empty
252
+ end
253
+
254
+ it "adds dependencies" do
255
+ node = described_class.new(id: "test", name: "Test")
256
+ node.add_dependency("dep1", "variable1")
257
+
258
+ expect(node.information_requirements.length).to eq(1)
259
+ expect(node.information_requirements.first[:decision_id]).to eq("dep1")
260
+ expect(node.information_requirements.first[:variable_name]).to eq("variable1")
261
+ end
262
+
263
+ it "checks if node depends on another decision" do
264
+ node = described_class.new(id: "test", name: "Test")
265
+ node.add_dependency("dep1")
266
+
267
+ expect(node.depends_on?("dep1")).to be true
268
+ expect(node.depends_on?("dep2")).to be false
269
+ end
270
+
271
+ it "resets evaluation state" do
272
+ node = described_class.new(id: "test", name: "Test")
273
+ node.value = "some value"
274
+ node.evaluated = true
275
+
276
+ node.reset!
277
+
278
+ expect(node.value).to be_nil
279
+ expect(node.evaluated).to be false
280
+ end
281
+ end
282
+ end