decision_agent 0.1.7 → 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 (56) 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 +1132 -12
  24. data/lib/decision_agent/dsl/schema_validator.rb +12 -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/app.js +119 -1
  29. data/lib/decision_agent/web/public/dmn-editor.css +596 -0
  30. data/lib/decision_agent/web/public/dmn-editor.html +250 -0
  31. data/lib/decision_agent/web/public/dmn-editor.js +553 -0
  32. data/lib/decision_agent/web/public/index.html +71 -0
  33. data/lib/decision_agent/web/public/styles.css +21 -0
  34. data/lib/decision_agent/web/server.rb +465 -0
  35. data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
  36. data/spec/advanced_operators_spec.rb +2147 -0
  37. data/spec/auth/rbac_adapter_spec.rb +228 -0
  38. data/spec/dmn/decision_graph_spec.rb +282 -0
  39. data/spec/dmn/decision_tree_spec.rb +203 -0
  40. data/spec/dmn/feel/errors_spec.rb +18 -0
  41. data/spec/dmn/feel/functions_spec.rb +400 -0
  42. data/spec/dmn/feel/simple_parser_spec.rb +274 -0
  43. data/spec/dmn/feel/types_spec.rb +176 -0
  44. data/spec/dmn/feel_parser_spec.rb +489 -0
  45. data/spec/dmn/hit_policy_spec.rb +202 -0
  46. data/spec/dmn/integration_spec.rb +226 -0
  47. data/spec/examples.txt +1909 -0
  48. data/spec/fixtures/dmn/complex_decision.dmn +81 -0
  49. data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
  50. data/spec/fixtures/dmn/simple_decision.dmn +40 -0
  51. data/spec/monitoring/metrics_collector_spec.rb +37 -35
  52. data/spec/monitoring/monitored_agent_spec.rb +14 -11
  53. data/spec/performance_optimizations_spec.rb +10 -3
  54. data/spec/thread_safety_spec.rb +10 -2
  55. data/spec/web_ui_rack_spec.rb +294 -0
  56. metadata +66 -1
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "feel/evaluator"
4
+ require_relative "errors"
5
+ require_relative "decision_tree"
6
+
7
+ module DecisionAgent
8
+ module Dmn
9
+ # Represents a decision in a decision graph
10
+ class DecisionNode
11
+ attr_reader :id, :name, :decision_logic, :information_requirements
12
+ attr_accessor :value, :evaluated
13
+
14
+ def initialize(id:, name:, decision_logic: nil)
15
+ @id = id
16
+ @name = name
17
+ @decision_logic = decision_logic # Can be DecisionTable, DecisionTree, or literal
18
+ @information_requirements = [] # Dependencies on other decisions
19
+ @value = nil
20
+ @evaluated = false
21
+ end
22
+
23
+ def add_dependency(decision_id, variable_name = nil)
24
+ @information_requirements << {
25
+ decision_id: decision_id,
26
+ variable_name: variable_name || decision_id
27
+ }
28
+ end
29
+
30
+ def depends_on?(decision_id)
31
+ @information_requirements.any? { |req| req[:decision_id] == decision_id }
32
+ end
33
+
34
+ def reset!
35
+ @value = nil
36
+ @evaluated = false
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ id: @id,
42
+ name: @name,
43
+ information_requirements: @information_requirements,
44
+ decision_logic_type: decision_logic_type
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def decision_logic_type
51
+ case @decision_logic
52
+ when DecisionTree
53
+ "decision_tree"
54
+ when Hash
55
+ "decision_table"
56
+ else
57
+ "literal"
58
+ end
59
+ end
60
+ end
61
+
62
+ # Represents and evaluates a decision graph (DMN model with multiple decisions)
63
+ class DecisionGraph
64
+ attr_reader :id, :name, :decisions
65
+
66
+ def initialize(id:, name:)
67
+ @id = id
68
+ @name = name
69
+ @decisions = {} # decision_id => DecisionNode
70
+ @feel_evaluator = Feel::Evaluator.new
71
+ end
72
+
73
+ def add_decision(decision)
74
+ @decisions[decision.id] = decision
75
+ end
76
+
77
+ def get_decision(decision_id)
78
+ @decisions[decision_id]
79
+ end
80
+
81
+ # Evaluate a specific decision (and all its dependencies)
82
+ def evaluate(decision_id, context)
83
+ decision = @decisions[decision_id]
84
+ raise DmnError, "Decision '#{decision_id}' not found" unless decision
85
+
86
+ # Reset all decision evaluations
87
+ reset_all!
88
+
89
+ # Build evaluation context
90
+ eval_context = context.is_a?(Hash) ? context : context.to_h
91
+
92
+ # Evaluate the requested decision (will recursively evaluate dependencies)
93
+ evaluate_decision(decision, eval_context)
94
+ end
95
+
96
+ # Evaluate all decisions in the graph
97
+ def evaluate_all(context)
98
+ reset_all!
99
+ eval_context = context.is_a?(Hash) ? context : context.to_h
100
+
101
+ results = {}
102
+ @decisions.each do |decision_id, decision|
103
+ results[decision_id] = evaluate_decision(decision, eval_context) unless decision.evaluated
104
+ end
105
+
106
+ results
107
+ end
108
+
109
+ # Get decisions in topological order (respecting dependencies)
110
+ def topological_order
111
+ order = []
112
+ visited = Set.new
113
+ temp_mark = Set.new
114
+
115
+ visit = lambda do |decision_id|
116
+ return if visited.include?(decision_id)
117
+
118
+ raise DmnError, "Circular dependency detected involving decision '#{decision_id}'" if temp_mark.include?(decision_id)
119
+
120
+ temp_mark.add(decision_id)
121
+
122
+ decision = @decisions[decision_id]
123
+ decision.information_requirements.each do |req|
124
+ visit.call(req[:decision_id]) if @decisions[req[:decision_id]]
125
+ end
126
+
127
+ temp_mark.delete(decision_id)
128
+ visited.add(decision_id)
129
+ order << decision_id
130
+ end
131
+
132
+ @decisions.each_key { |decision_id| visit.call(decision_id) }
133
+ order
134
+ end
135
+
136
+ # Detect circular dependencies
137
+ def circular_dependencies?
138
+ topological_order
139
+ false
140
+ rescue DmnError => e
141
+ e.message.include?("Circular dependency")
142
+ end
143
+
144
+ # Get all leaf decisions (no other decisions depend on them)
145
+ def leaf_decisions
146
+ dependent_decisions = Set.new
147
+ @decisions.each_value do |decision|
148
+ decision.information_requirements.each do |req|
149
+ dependent_decisions.add(req[:decision_id])
150
+ end
151
+ end
152
+
153
+ @decisions.keys.reject { |id| dependent_decisions.include?(id) }
154
+ end
155
+
156
+ # Get all root decisions (don't depend on other decisions)
157
+ def root_decisions
158
+ @decisions.select { |_id, decision| decision.information_requirements.empty? }.keys
159
+ end
160
+
161
+ # Get the dependency graph as a hash
162
+ def dependency_graph
163
+ graph = {}
164
+ @decisions.each do |id, decision|
165
+ graph[id] = decision.information_requirements.map { |req| req[:decision_id] }
166
+ end
167
+ graph
168
+ end
169
+
170
+ # Export graph structure
171
+ def to_h
172
+ {
173
+ id: @id,
174
+ name: @name,
175
+ decisions: @decisions.transform_values(&:to_h),
176
+ dependency_graph: dependency_graph
177
+ }
178
+ end
179
+
180
+ private
181
+
182
+ def reset_all!
183
+ @decisions.each_value(&:reset!)
184
+ end
185
+
186
+ def evaluate_decision(decision, context)
187
+ # If already evaluated, return cached value
188
+ return decision.value if decision.evaluated
189
+
190
+ # First, evaluate all dependencies
191
+ decision.information_requirements.each do |req|
192
+ dep_decision = @decisions[req[:decision_id]]
193
+ next unless dep_decision
194
+
195
+ # Recursively evaluate dependency
196
+ dep_value = evaluate_decision(dep_decision, context)
197
+
198
+ # Add dependency result to context with the specified variable name
199
+ context[req[:variable_name]] = dep_value
200
+ end
201
+
202
+ # Now evaluate this decision with the enriched context
203
+ decision.value = evaluate_decision_logic(decision, context)
204
+ decision.evaluated = true
205
+ decision.value
206
+ end
207
+
208
+ def evaluate_decision_logic(decision, context)
209
+ case decision.decision_logic
210
+ when DecisionTree
211
+ # Evaluate decision tree
212
+ decision.decision_logic.evaluate(context)
213
+ when Hash
214
+ # Evaluate decision table (simplified)
215
+ evaluate_decision_table(decision.decision_logic, context)
216
+ when String
217
+ # Evaluate as FEEL expression (literal expression)
218
+ @feel_evaluator.evaluate(decision.decision_logic, "result", context)
219
+ when Proc
220
+ # Execute custom logic with string keys
221
+ string_context = context.transform_keys(&:to_s)
222
+ decision.decision_logic.call(string_context)
223
+ else
224
+ # Return as-is
225
+ decision.decision_logic
226
+ end
227
+ rescue StandardError => e
228
+ raise DmnError, "Failed to evaluate decision '#{decision.id}': #{e.message}"
229
+ end
230
+
231
+ def evaluate_decision_table(table, context)
232
+ # Simplified decision table evaluation
233
+ # In a full implementation, this would delegate to the DecisionTable evaluator
234
+ rules = table[:rules] || []
235
+
236
+ matching_rule = rules.find do |rule|
237
+ rule[:conditions].all? do |input_id, condition|
238
+ value = context[input_id]
239
+ evaluate_condition(condition, value, context)
240
+ end
241
+ end
242
+
243
+ matching_rule ? matching_rule[:output] : nil
244
+ end
245
+
246
+ def evaluate_condition(condition, value, context)
247
+ return true if condition.nil? || condition == "-"
248
+
249
+ @feel_evaluator.evaluate(
250
+ "#{value} #{condition}",
251
+ "condition",
252
+ context
253
+ )
254
+ rescue StandardError
255
+ false
256
+ end
257
+ end
258
+
259
+ # Parser for DMN decision graphs from XML
260
+ class DecisionGraphParser
261
+ def self.parse(xml_doc)
262
+ # Extract namespace and model info
263
+ definitions = xml_doc.at_xpath("//dmn:definitions") || xml_doc.root
264
+ model_id = definitions["id"] || "decision_graph"
265
+ model_name = definitions["name"] || model_id
266
+
267
+ graph = DecisionGraph.new(id: model_id, name: model_name)
268
+
269
+ # Parse all decisions
270
+ decisions = xml_doc.xpath("//dmn:decision")
271
+ decisions.each do |decision_xml|
272
+ decision_node = parse_decision_node(decision_xml)
273
+ graph.add_decision(decision_node)
274
+
275
+ # Parse information requirements (dependencies)
276
+ decision_id = decision_xml["id"]
277
+ decision_node = graph.get_decision(decision_id)
278
+
279
+ # Find all information requirements
280
+ info_reqs = decision_xml.xpath(".//dmn:informationRequirement")
281
+ info_reqs.each do |req|
282
+ required_decision = req.at_xpath(".//dmn:requiredDecision")
283
+ if required_decision
284
+ required_id = required_decision["href"]&.sub("#", "")
285
+ decision_node.add_dependency(required_id) if required_id
286
+ end
287
+ end
288
+ end
289
+
290
+ graph
291
+ end
292
+
293
+ def self.parse_decision_node(decision_xml)
294
+ decision_id = decision_xml["id"]
295
+ decision_name = decision_xml["name"] || decision_id
296
+
297
+ # Check for decision table
298
+ decision_table = decision_xml.at_xpath(".//dmn:decisionTable")
299
+ if decision_table
300
+ # Parse decision table (simplified)
301
+ decision_logic = parse_decision_table(decision_table)
302
+ else
303
+ # Check for literal expression (could be decision tree or simple expression)
304
+ literal_expr = decision_xml.at_xpath(".//dmn:literalExpression")
305
+ decision_logic = literal_expr&.text&.strip
306
+ end
307
+
308
+ DecisionNode.new(
309
+ id: decision_id,
310
+ name: decision_name,
311
+ decision_logic: decision_logic
312
+ )
313
+ end
314
+
315
+ def self.parse_decision_table(table_xml)
316
+ # Simplified decision table parsing
317
+ # Full implementation would use the existing DecisionTable parser
318
+ {
319
+ type: "decision_table",
320
+ hit_policy: table_xml["hitPolicy"] || "UNIQUE",
321
+ inputs: [],
322
+ rules: []
323
+ }
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "feel/evaluator"
4
+ require_relative "errors"
5
+
6
+ module DecisionAgent
7
+ module Dmn
8
+ # Represents a node in a decision tree
9
+ class TreeNode
10
+ attr_reader :id, :label, :condition, :decision, :children
11
+ attr_accessor :parent
12
+
13
+ def initialize(id:, label: nil, condition: nil, decision: nil)
14
+ @id = id
15
+ @label = label
16
+ @condition = condition # FEEL expression to evaluate
17
+ @decision = decision # Output decision if this is a leaf node
18
+ @children = []
19
+ @parent = nil
20
+ end
21
+
22
+ def add_child(node)
23
+ node.parent = self
24
+ @children << node
25
+ end
26
+
27
+ def leaf?
28
+ @children.empty?
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ id: @id,
34
+ label: @label,
35
+ condition: @condition,
36
+ decision: @decision,
37
+ children: @children.map(&:to_h)
38
+ }
39
+ end
40
+ end
41
+
42
+ # Evaluates decision trees
43
+ class DecisionTree
44
+ attr_reader :id, :name, :root
45
+
46
+ def initialize(id:, name:, root: nil)
47
+ @id = id
48
+ @name = name
49
+ @root = root || TreeNode.new(id: "root", label: "Root")
50
+ @feel_evaluator = Feel::Evaluator.new
51
+ end
52
+
53
+ # Evaluate the decision tree with given context
54
+ def evaluate(context)
55
+ traverse(@root, context)
56
+ end
57
+
58
+ # Build a decision tree from a hash representation
59
+ def self.from_hash(hash)
60
+ tree = new(id: hash[:id], name: hash[:name])
61
+ tree.instance_variable_set(:@root, build_node(hash[:root]))
62
+ tree
63
+ end
64
+
65
+ # Convert tree to hash representation
66
+ def to_h
67
+ {
68
+ id: @id,
69
+ name: @name,
70
+ root: @root.to_h
71
+ }
72
+ end
73
+
74
+ # Get all leaf nodes (decision outcomes)
75
+ def leaf_nodes
76
+ collect_leaf_nodes(@root)
77
+ end
78
+
79
+ # Get tree depth
80
+ def depth
81
+ calculate_depth(@root)
82
+ end
83
+
84
+ # Get all paths from root to leaves
85
+ def paths
86
+ collect_paths(@root, [])
87
+ end
88
+
89
+ private
90
+
91
+ def traverse(node, context)
92
+ # If this is a leaf node, return the decision
93
+ return node.decision if node.leaf?
94
+
95
+ # Track if any condition was successfully evaluated
96
+ any_condition_evaluated = false
97
+ has_children_with_conditions = node.children.any?(&:condition)
98
+
99
+ # Evaluate each child's condition until we find a match
100
+ node.children.each do |child|
101
+ next unless child.condition
102
+
103
+ begin
104
+ result = @feel_evaluator.evaluate(child.condition, "condition", context.to_h)
105
+ any_condition_evaluated = true
106
+
107
+ return traverse(child, context) if result
108
+ # Condition matched, continue down this branch
109
+
110
+ # Condition evaluated to false - check if this child has a false branch
111
+ # If child has multiple leaf children with no conditions, take the second one
112
+ if !child.leaf? && child.children.all? { |c| c.condition.nil? && c.leaf? } && child.children.size > 1
113
+ return child.children[1].decision
114
+ end
115
+ rescue StandardError
116
+ # If condition evaluation fails, skip this branch
117
+ next
118
+ end
119
+ end
120
+
121
+ # No matching condition found, check for a default branch (no condition)
122
+ # Take default if: no children have conditions, OR at least one condition was successfully evaluated
123
+ if !has_children_with_conditions || any_condition_evaluated
124
+ default_child = node.children.find { |c| c.condition.nil? }
125
+ return traverse(default_child, context) if default_child
126
+ end
127
+
128
+ # No match found
129
+ nil
130
+ end
131
+
132
+ def self.build_node(hash)
133
+ node = TreeNode.new(
134
+ id: hash[:id],
135
+ label: hash[:label],
136
+ condition: hash[:condition],
137
+ decision: hash[:decision]
138
+ )
139
+
140
+ hash[:children]&.each do |child_hash|
141
+ node.add_child(build_node(child_hash))
142
+ end
143
+
144
+ node
145
+ end
146
+
147
+ def collect_leaf_nodes(node, leaves = [])
148
+ if node.leaf?
149
+ leaves << node
150
+ else
151
+ node.children.each { |child| collect_leaf_nodes(child, leaves) }
152
+ end
153
+ leaves
154
+ end
155
+
156
+ def calculate_depth(node, current_depth = 0)
157
+ return current_depth if node.leaf?
158
+
159
+ max_child_depth = node.children.map { |child| calculate_depth(child, current_depth + 1) }.max
160
+ max_child_depth || current_depth
161
+ end
162
+
163
+ def collect_paths(node, current_path, paths = [])
164
+ current_path += [node]
165
+
166
+ if node.leaf?
167
+ paths << current_path
168
+ else
169
+ node.children.each { |child| collect_paths(child, current_path, paths) }
170
+ end
171
+
172
+ paths
173
+ end
174
+ end
175
+
176
+ # Parser for DMN decision trees (literal expressions)
177
+ class DecisionTreeParser
178
+ def self.parse(xml_element)
179
+ # Parse DMN literal expression (decision tree representation)
180
+ # This is a simplified parser - full DMN tree parsing would be more complex
181
+ tree_id = xml_element["id"]
182
+ tree_name = xml_element["name"] || tree_id
183
+
184
+ DecisionTree.new(id: tree_id, name: tree_name)
185
+
186
+ # Parse the tree structure from XML
187
+ # Note: This is a placeholder for full DMN literal expression parsing
188
+ # In a complete implementation, this would parse the DMN tree structure
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,30 @@
1
+ module DecisionAgent
2
+ module Dmn
3
+ # Base error for all DMN-related errors
4
+ class DmnError < StandardError; end
5
+
6
+ # Raised when DMN XML is invalid or malformed
7
+ class InvalidDmnXmlError < DmnError; end
8
+
9
+ # Raised when DMN model structure is invalid
10
+ class InvalidDmnModelError < DmnError; end
11
+
12
+ # Raised when hit policy is unsupported
13
+ class UnsupportedHitPolicyError < DmnError; end
14
+
15
+ # Raised when FEEL expression cannot be parsed
16
+ class FeelParseError < DmnError; end
17
+
18
+ # Raised when parse tree transformation to AST fails
19
+ class FeelTransformError < DmnError; end
20
+
21
+ # Raised when FEEL expression evaluation fails
22
+ class FeelEvaluationError < DmnError; end
23
+
24
+ # Raised when type conversion or type checking fails
25
+ class FeelTypeError < DmnError; end
26
+
27
+ # Raised when a function is not found or has invalid arguments
28
+ class FeelFunctionError < DmnError; end
29
+ end
30
+ end