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,275 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Dsl
|
|
3
|
+
# JSON Schema validator for Decision Agent rule DSL
|
|
4
|
+
# Provides comprehensive validation with detailed error messages
|
|
5
|
+
class SchemaValidator
|
|
6
|
+
SUPPORTED_OPERATORS = %w[eq neq gt gte lt lte in present blank].freeze
|
|
7
|
+
|
|
8
|
+
CONDITION_TYPES = %w[all any field].freeze
|
|
9
|
+
|
|
10
|
+
# Validates the entire ruleset structure
|
|
11
|
+
def self.validate!(data)
|
|
12
|
+
new(data).validate!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(data)
|
|
16
|
+
@data = data
|
|
17
|
+
@errors = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate!
|
|
21
|
+
validate_root_structure
|
|
22
|
+
validate_version
|
|
23
|
+
validate_rules_array
|
|
24
|
+
validate_each_rule
|
|
25
|
+
|
|
26
|
+
raise InvalidRuleDslError, format_errors if @errors.any?
|
|
27
|
+
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validate_root_structure
|
|
34
|
+
unless @data.is_a?(Hash)
|
|
35
|
+
@errors << "Root element must be a hash/object, got #{@data.class}"
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_version
|
|
41
|
+
return if @errors.any? # Skip if root structure is invalid
|
|
42
|
+
|
|
43
|
+
unless @data.key?("version") || @data.key?(:version)
|
|
44
|
+
@errors << "Missing required field 'version'. Example: { \"version\": \"1.0\", ... }"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_rules_array
|
|
49
|
+
return if @errors.any?
|
|
50
|
+
|
|
51
|
+
rules = @data["rules"] || @data[:rules]
|
|
52
|
+
|
|
53
|
+
unless rules
|
|
54
|
+
@errors << "Missing required field 'rules'. Expected an array of rule objects."
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
unless rules.is_a?(Array)
|
|
59
|
+
@errors << "Field 'rules' must be an array, got #{rules.class}. Example: \"rules\": [...]"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_each_rule
|
|
64
|
+
return if @errors.any?
|
|
65
|
+
|
|
66
|
+
rules = @data["rules"] || @data[:rules]
|
|
67
|
+
return unless rules.is_a?(Array)
|
|
68
|
+
|
|
69
|
+
rules.each_with_index do |rule, idx|
|
|
70
|
+
validate_rule(rule, idx)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_rule(rule, idx)
|
|
75
|
+
rule_path = "rules[#{idx}]"
|
|
76
|
+
|
|
77
|
+
unless rule.is_a?(Hash)
|
|
78
|
+
@errors << "#{rule_path}: Rule must be a hash/object, got #{rule.class}"
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Validate required fields
|
|
83
|
+
validate_rule_id(rule, rule_path)
|
|
84
|
+
validate_if_clause(rule, rule_path)
|
|
85
|
+
validate_then_clause(rule, rule_path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_rule_id(rule, rule_path)
|
|
89
|
+
rule_id = rule["id"] || rule[:id]
|
|
90
|
+
|
|
91
|
+
unless rule_id
|
|
92
|
+
@errors << "#{rule_path}: Missing required field 'id'. Each rule must have a unique identifier."
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
unless rule_id.is_a?(String) || rule_id.is_a?(Symbol)
|
|
97
|
+
@errors << "#{rule_path}: Field 'id' must be a string, got #{rule_id.class}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_if_clause(rule, rule_path)
|
|
102
|
+
if_clause = rule["if"] || rule[:if]
|
|
103
|
+
|
|
104
|
+
unless if_clause
|
|
105
|
+
@errors << "#{rule_path}: Missing required field 'if'. " \
|
|
106
|
+
"Expected a condition object with 'field', 'all', or 'any'."
|
|
107
|
+
return
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
validate_condition(if_clause, "#{rule_path}.if")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_condition(condition, path)
|
|
114
|
+
unless condition.is_a?(Hash)
|
|
115
|
+
@errors << "#{path}: Condition must be a hash/object, got #{condition.class}"
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
condition_type = detect_condition_type(condition)
|
|
120
|
+
|
|
121
|
+
unless condition_type
|
|
122
|
+
@errors << "#{path}: Condition must have one of: 'field', 'all', or 'any'. " \
|
|
123
|
+
"Example: { \"field\": \"status\", \"op\": \"eq\", \"value\": \"active\" }"
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
case condition_type
|
|
128
|
+
when "field"
|
|
129
|
+
validate_field_condition(condition, path)
|
|
130
|
+
when "all"
|
|
131
|
+
validate_all_condition(condition, path)
|
|
132
|
+
when "any"
|
|
133
|
+
validate_any_condition(condition, path)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def detect_condition_type(condition)
|
|
138
|
+
if condition.key?("field") || condition.key?(:field)
|
|
139
|
+
"field"
|
|
140
|
+
elsif condition.key?("all") || condition.key?(:all)
|
|
141
|
+
"all"
|
|
142
|
+
elsif condition.key?("any") || condition.key?(:any)
|
|
143
|
+
"any"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_field_condition(condition, path)
|
|
148
|
+
field = condition["field"] || condition[:field]
|
|
149
|
+
operator = condition["op"] || condition[:op]
|
|
150
|
+
value = condition["value"] || condition[:value]
|
|
151
|
+
|
|
152
|
+
# Validate field
|
|
153
|
+
unless field
|
|
154
|
+
@errors << "#{path}: Field condition missing 'field' key"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Validate operator
|
|
158
|
+
unless operator
|
|
159
|
+
@errors << "#{path}: Field condition missing 'op' (operator) key"
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
validate_operator(operator, path)
|
|
164
|
+
|
|
165
|
+
# Validate value (not required for 'present' and 'blank')
|
|
166
|
+
if !%w[present blank].include?(operator.to_s) && value.nil?
|
|
167
|
+
@errors << "#{path}: Field condition missing 'value' key for operator '#{operator}'"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Validate dot-notation in field path
|
|
171
|
+
validate_field_path(field, path) if field
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_operator(operator, path)
|
|
175
|
+
operator_str = operator.to_s
|
|
176
|
+
|
|
177
|
+
unless SUPPORTED_OPERATORS.include?(operator_str)
|
|
178
|
+
@errors << "#{path}: Unsupported operator '#{operator}'. " \
|
|
179
|
+
"Supported operators: #{SUPPORTED_OPERATORS.join(', ')}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_field_path(field, path)
|
|
184
|
+
return unless field.is_a?(String)
|
|
185
|
+
|
|
186
|
+
if field.empty?
|
|
187
|
+
@errors << "#{path}: Field path cannot be empty"
|
|
188
|
+
return
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Validate dot-notation
|
|
192
|
+
parts = field.split(".")
|
|
193
|
+
|
|
194
|
+
if parts.any?(&:empty?)
|
|
195
|
+
@errors << "#{path}: Invalid field path '#{field}'. " \
|
|
196
|
+
"Dot-notation paths cannot have empty segments. " \
|
|
197
|
+
"Example: 'user.profile.role'"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def validate_all_condition(condition, path)
|
|
202
|
+
sub_conditions = condition["all"] || condition[:all]
|
|
203
|
+
|
|
204
|
+
unless sub_conditions.is_a?(Array)
|
|
205
|
+
@errors << "#{path}: 'all' condition must contain an array of conditions, got #{sub_conditions.class}"
|
|
206
|
+
return
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
sub_conditions.each_with_index do |sub_cond, idx|
|
|
210
|
+
validate_condition(sub_cond, "#{path}.all[#{idx}]")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_any_condition(condition, path)
|
|
215
|
+
sub_conditions = condition["any"] || condition[:any]
|
|
216
|
+
|
|
217
|
+
unless sub_conditions.is_a?(Array)
|
|
218
|
+
@errors << "#{path}: 'any' condition must contain an array of conditions, got #{sub_conditions.class}"
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
sub_conditions.each_with_index do |sub_cond, idx|
|
|
223
|
+
validate_condition(sub_cond, "#{path}.any[#{idx}]")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def validate_then_clause(rule, rule_path)
|
|
228
|
+
then_clause = rule["then"] || rule[:then]
|
|
229
|
+
|
|
230
|
+
unless then_clause
|
|
231
|
+
@errors << "#{rule_path}: Missing required field 'then'. " \
|
|
232
|
+
"Expected an object with 'decision' field."
|
|
233
|
+
return
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
unless then_clause.is_a?(Hash)
|
|
237
|
+
@errors << "#{rule_path}.then: Must be a hash/object, got #{then_clause.class}"
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Validate decision
|
|
242
|
+
decision = then_clause["decision"] || then_clause[:decision]
|
|
243
|
+
|
|
244
|
+
unless decision
|
|
245
|
+
@errors << "#{rule_path}.then: Missing required field 'decision'"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Validate optional weight
|
|
249
|
+
weight = then_clause["weight"] || then_clause[:weight]
|
|
250
|
+
|
|
251
|
+
if weight && !weight.is_a?(Numeric)
|
|
252
|
+
@errors << "#{rule_path}.then.weight: Must be a number, got #{weight.class}"
|
|
253
|
+
elsif weight && (weight < 0.0 || weight > 1.0)
|
|
254
|
+
@errors << "#{rule_path}.then.weight: Must be between 0.0 and 1.0, got #{weight}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Validate optional reason
|
|
258
|
+
reason = then_clause["reason"] || then_clause[:reason]
|
|
259
|
+
|
|
260
|
+
if reason && !reason.is_a?(String)
|
|
261
|
+
@errors << "#{rule_path}.then.reason: Must be a string, got #{reason.class}"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def format_errors
|
|
266
|
+
header = "Rule DSL validation failed with #{@errors.size} error#{'s' if @errors.size > 1}:\n\n"
|
|
267
|
+
numbered_errors = @errors.map.with_index { |err, idx| " #{idx + 1}. #{err}" }.join("\n")
|
|
268
|
+
|
|
269
|
+
footer = "\n\nFor documentation on the rule DSL format, see the README."
|
|
270
|
+
|
|
271
|
+
header + numbered_errors + footer
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class InvalidRuleDslError < Error
|
|
5
|
+
def initialize(message = "Invalid rule DSL structure")
|
|
6
|
+
super(message)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class NoEvaluationsError < Error
|
|
11
|
+
def initialize(message = "No evaluators returned a decision")
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class ReplayMismatchError < Error
|
|
17
|
+
attr_reader :expected, :actual, :differences
|
|
18
|
+
|
|
19
|
+
def initialize(expected:, actual:, differences:)
|
|
20
|
+
@expected = expected
|
|
21
|
+
@actual = actual
|
|
22
|
+
@differences = differences
|
|
23
|
+
super("Replay mismatch detected: #{differences.join(', ')}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class InvalidConfigurationError < Error
|
|
28
|
+
def initialize(message = "Invalid agent configuration")
|
|
29
|
+
super(message)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class InvalidEvaluatorError < Error
|
|
34
|
+
def initialize(message = "Evaluator must respond to #evaluate")
|
|
35
|
+
super(message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class InvalidScoringStrategyError < Error
|
|
40
|
+
def initialize(message = "Scoring strategy must respond to #score")
|
|
41
|
+
super(message)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class InvalidAuditAdapterError < Error
|
|
46
|
+
def initialize(message = "Audit adapter must respond to #record")
|
|
47
|
+
super(message)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class InvalidConfidenceError < Error
|
|
52
|
+
def initialize(confidence)
|
|
53
|
+
super("Confidence must be between 0.0 and 1.0, got: #{confidence}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class InvalidWeightError < Error
|
|
58
|
+
def initialize(weight)
|
|
59
|
+
super("Weight must be between 0.0 and 1.0, got: #{weight}")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
class Evaluation
|
|
3
|
+
attr_reader :decision, :weight, :reason, :evaluator_name, :metadata
|
|
4
|
+
|
|
5
|
+
def initialize(decision:, weight:, reason:, evaluator_name:, metadata: {})
|
|
6
|
+
validate_weight!(weight)
|
|
7
|
+
|
|
8
|
+
@decision = decision.to_s.freeze
|
|
9
|
+
@weight = weight.to_f
|
|
10
|
+
@reason = reason.to_s.freeze
|
|
11
|
+
@evaluator_name = evaluator_name.to_s.freeze
|
|
12
|
+
@metadata = deep_freeze(metadata)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
decision: @decision,
|
|
18
|
+
weight: @weight,
|
|
19
|
+
reason: @reason,
|
|
20
|
+
evaluator_name: @evaluator_name,
|
|
21
|
+
metadata: @metadata
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ==(other)
|
|
26
|
+
other.is_a?(Evaluation) &&
|
|
27
|
+
@decision == other.decision &&
|
|
28
|
+
@weight == other.weight &&
|
|
29
|
+
@reason == other.reason &&
|
|
30
|
+
@evaluator_name == other.evaluator_name &&
|
|
31
|
+
@metadata == other.metadata
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def validate_weight!(weight)
|
|
37
|
+
w = weight.to_f
|
|
38
|
+
raise InvalidWeightError.new(weight) unless w >= 0.0 && w <= 1.0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def deep_freeze(obj)
|
|
42
|
+
case obj
|
|
43
|
+
when Hash
|
|
44
|
+
obj.transform_values { |v| deep_freeze(v) }.freeze
|
|
45
|
+
when Array
|
|
46
|
+
obj.map { |v| deep_freeze(v) }.freeze
|
|
47
|
+
else
|
|
48
|
+
obj.freeze
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Evaluators
|
|
3
|
+
class Base
|
|
4
|
+
def evaluate(context, feedback: {})
|
|
5
|
+
raise NotImplementedError, "Subclasses must implement #evaluate"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def evaluator_name
|
|
11
|
+
self.class.name.split("::").last
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module Evaluators
|
|
5
|
+
class JsonRuleEvaluator < Base
|
|
6
|
+
attr_reader :ruleset_name
|
|
7
|
+
|
|
8
|
+
def initialize(rules_json:, name: nil)
|
|
9
|
+
@rules_json = rules_json.is_a?(String) ? rules_json : JSON.generate(rules_json)
|
|
10
|
+
@ruleset = Dsl::RuleParser.parse(@rules_json)
|
|
11
|
+
@ruleset_name = @ruleset["ruleset"] || "unknown"
|
|
12
|
+
@name = name || "JsonRuleEvaluator(#{@ruleset_name})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def evaluate(context, feedback: {})
|
|
16
|
+
ctx = context.is_a?(Context) ? context : Context.new(context)
|
|
17
|
+
|
|
18
|
+
matched_rule = find_first_matching_rule(ctx)
|
|
19
|
+
|
|
20
|
+
return nil unless matched_rule
|
|
21
|
+
|
|
22
|
+
then_clause = matched_rule["then"]
|
|
23
|
+
|
|
24
|
+
Evaluation.new(
|
|
25
|
+
decision: then_clause["decision"],
|
|
26
|
+
weight: then_clause["weight"] || 1.0,
|
|
27
|
+
reason: then_clause["reason"] || "Rule matched",
|
|
28
|
+
evaluator_name: @name,
|
|
29
|
+
metadata: {
|
|
30
|
+
type: "json_rule",
|
|
31
|
+
rule_id: matched_rule["id"],
|
|
32
|
+
ruleset: @ruleset_name
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def find_first_matching_rule(context)
|
|
40
|
+
rules = @ruleset["rules"] || []
|
|
41
|
+
|
|
42
|
+
rules.find do |rule|
|
|
43
|
+
if_clause = rule["if"]
|
|
44
|
+
next false unless if_clause
|
|
45
|
+
|
|
46
|
+
Dsl::ConditionEvaluator.evaluate(if_clause, context)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Evaluators
|
|
3
|
+
class StaticEvaluator < Base
|
|
4
|
+
attr_reader :decision, :weight, :reason, :name, :custom_metadata
|
|
5
|
+
|
|
6
|
+
def initialize(decision:, weight: 1.0, reason: "Static decision", name: nil, metadata: nil)
|
|
7
|
+
@decision = decision
|
|
8
|
+
@weight = weight.to_f
|
|
9
|
+
@reason = reason
|
|
10
|
+
@name = name || evaluator_name
|
|
11
|
+
@custom_metadata = metadata
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def evaluate(context, feedback: {})
|
|
15
|
+
metadata = if @custom_metadata
|
|
16
|
+
@custom_metadata
|
|
17
|
+
else
|
|
18
|
+
{ type: "static" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Evaluation.new(
|
|
22
|
+
decision: @decision,
|
|
23
|
+
weight: @weight,
|
|
24
|
+
reason: @reason,
|
|
25
|
+
evaluator_name: @name,
|
|
26
|
+
metadata: metadata
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Replay
|
|
6
|
+
def self.run(audit_payload, strict: true)
|
|
7
|
+
validate_payload!(audit_payload)
|
|
8
|
+
|
|
9
|
+
context = Context.new(audit_payload[:context] || audit_payload["context"])
|
|
10
|
+
feedback = audit_payload[:feedback] || audit_payload["feedback"] || {}
|
|
11
|
+
|
|
12
|
+
original_evaluations = parse_evaluations(audit_payload)
|
|
13
|
+
original_decision = audit_payload[:decision] || audit_payload["decision"]
|
|
14
|
+
original_confidence = audit_payload[:confidence] || audit_payload["confidence"]
|
|
15
|
+
|
|
16
|
+
scoring_strategy = instantiate_scoring_strategy(audit_payload)
|
|
17
|
+
|
|
18
|
+
agent = Agent.new(
|
|
19
|
+
evaluators: build_replay_evaluators(original_evaluations),
|
|
20
|
+
scoring_strategy: scoring_strategy,
|
|
21
|
+
audit_adapter: Audit::NullAdapter.new
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
replayed_result = agent.decide(context: context, feedback: feedback)
|
|
25
|
+
|
|
26
|
+
if strict
|
|
27
|
+
validate_strict_match!(
|
|
28
|
+
original_decision: original_decision,
|
|
29
|
+
original_confidence: original_confidence,
|
|
30
|
+
replayed_decision: replayed_result.decision,
|
|
31
|
+
replayed_confidence: replayed_result.confidence
|
|
32
|
+
)
|
|
33
|
+
else
|
|
34
|
+
log_differences(
|
|
35
|
+
original_decision: original_decision,
|
|
36
|
+
original_confidence: original_confidence,
|
|
37
|
+
replayed_decision: replayed_result.decision,
|
|
38
|
+
replayed_confidence: replayed_result.confidence
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
replayed_result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def self.validate_payload!(payload)
|
|
48
|
+
required_keys = ["context", "evaluations", "decision", "confidence"]
|
|
49
|
+
|
|
50
|
+
required_keys.each do |key|
|
|
51
|
+
unless payload.key?(key) || payload.key?(key.to_sym)
|
|
52
|
+
raise InvalidRuleDslError, "Audit payload missing required key: #{key}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.parse_evaluations(payload)
|
|
58
|
+
evals = payload[:evaluations] || payload["evaluations"]
|
|
59
|
+
|
|
60
|
+
evals.map do |eval_data|
|
|
61
|
+
if eval_data.is_a?(Evaluation)
|
|
62
|
+
eval_data
|
|
63
|
+
else
|
|
64
|
+
Evaluation.new(
|
|
65
|
+
decision: eval_data[:decision] || eval_data["decision"],
|
|
66
|
+
weight: eval_data[:weight] || eval_data["weight"],
|
|
67
|
+
reason: eval_data[:reason] || eval_data["reason"],
|
|
68
|
+
evaluator_name: eval_data[:evaluator_name] || eval_data["evaluator_name"],
|
|
69
|
+
metadata: eval_data[:metadata] || eval_data["metadata"] || {}
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.build_replay_evaluators(evaluations)
|
|
76
|
+
evaluations.map do |evaluation|
|
|
77
|
+
Evaluators::StaticEvaluator.new(
|
|
78
|
+
decision: evaluation.decision,
|
|
79
|
+
weight: evaluation.weight,
|
|
80
|
+
reason: evaluation.reason,
|
|
81
|
+
name: evaluation.evaluator_name,
|
|
82
|
+
metadata: evaluation.metadata
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.instantiate_scoring_strategy(payload)
|
|
88
|
+
strategy_name = payload[:scoring_strategy] || payload["scoring_strategy"]
|
|
89
|
+
|
|
90
|
+
return Scoring::WeightedAverage.new unless strategy_name
|
|
91
|
+
|
|
92
|
+
case strategy_name
|
|
93
|
+
when /WeightedAverage/
|
|
94
|
+
Scoring::WeightedAverage.new
|
|
95
|
+
when /MaxWeight/
|
|
96
|
+
Scoring::MaxWeight.new
|
|
97
|
+
when /Consensus/
|
|
98
|
+
Scoring::Consensus.new
|
|
99
|
+
when /Threshold/
|
|
100
|
+
Scoring::Threshold.new
|
|
101
|
+
else
|
|
102
|
+
Scoring::WeightedAverage.new
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.validate_strict_match!(original_decision:, original_confidence:, replayed_decision:, replayed_confidence:)
|
|
107
|
+
differences = []
|
|
108
|
+
|
|
109
|
+
if original_decision.to_s != replayed_decision.to_s
|
|
110
|
+
differences << "decision mismatch (expected: #{original_decision}, got: #{replayed_decision})"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
conf_diff = (original_confidence.to_f - replayed_confidence.to_f).abs
|
|
114
|
+
if conf_diff > 0.0001
|
|
115
|
+
differences << "confidence mismatch (expected: #{original_confidence}, got: #{replayed_confidence})"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if differences.any?
|
|
119
|
+
raise ReplayMismatchError.new(
|
|
120
|
+
expected: { decision: original_decision, confidence: original_confidence },
|
|
121
|
+
actual: { decision: replayed_decision, confidence: replayed_confidence },
|
|
122
|
+
differences: differences
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.log_differences(original_decision:, original_confidence:, replayed_decision:, replayed_confidence:)
|
|
128
|
+
differences = []
|
|
129
|
+
|
|
130
|
+
if original_decision.to_s != replayed_decision.to_s
|
|
131
|
+
differences << "Decision changed: #{original_decision} -> #{replayed_decision}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
conf_diff = (original_confidence.to_f - replayed_confidence.to_f).abs
|
|
135
|
+
if conf_diff > 0.0001
|
|
136
|
+
differences << "Confidence changed: #{original_confidence} -> #{replayed_confidence}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if differences.any?
|
|
140
|
+
warn "[DecisionAgent::Replay] Non-strict mode differences detected:"
|
|
141
|
+
differences.each { |diff| warn " - #{diff}" }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
differences
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Scoring
|
|
3
|
+
class Base
|
|
4
|
+
def score(evaluations)
|
|
5
|
+
raise NotImplementedError, "Subclasses must implement #score"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
protected
|
|
9
|
+
|
|
10
|
+
def normalize_confidence(value)
|
|
11
|
+
[[value, 0.0].max, 1.0].min
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def round_confidence(value)
|
|
15
|
+
(value * 10000).round / 10000.0
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Scoring
|
|
3
|
+
class Consensus < Base
|
|
4
|
+
attr_reader :minimum_agreement
|
|
5
|
+
|
|
6
|
+
def initialize(minimum_agreement: 0.5)
|
|
7
|
+
@minimum_agreement = minimum_agreement.to_f
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def score(evaluations)
|
|
11
|
+
return { decision: nil, confidence: 0.0 } if evaluations.empty?
|
|
12
|
+
|
|
13
|
+
grouped = evaluations.group_by(&:decision)
|
|
14
|
+
total_count = evaluations.size
|
|
15
|
+
|
|
16
|
+
candidates = grouped.map do |decision, evals|
|
|
17
|
+
agreement = evals.size.to_f / total_count
|
|
18
|
+
avg_weight = evals.sum(&:weight) / evals.size
|
|
19
|
+
|
|
20
|
+
[decision, agreement, avg_weight]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
candidates.sort_by! { |_, agreement, weight| [-agreement, -weight] }
|
|
24
|
+
|
|
25
|
+
winning_decision, agreement, avg_weight = candidates.first
|
|
26
|
+
|
|
27
|
+
if agreement >= @minimum_agreement
|
|
28
|
+
confidence = agreement * avg_weight
|
|
29
|
+
else
|
|
30
|
+
confidence = agreement * avg_weight * 0.5
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
decision: winning_decision,
|
|
35
|
+
confidence: round_confidence(normalize_confidence(confidence))
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|