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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1060 -0
  4. data/bin/decision_agent +104 -0
  5. data/lib/decision_agent/agent.rb +147 -0
  6. data/lib/decision_agent/audit/adapter.rb +9 -0
  7. data/lib/decision_agent/audit/logger_adapter.rb +27 -0
  8. data/lib/decision_agent/audit/null_adapter.rb +8 -0
  9. data/lib/decision_agent/context.rb +42 -0
  10. data/lib/decision_agent/decision.rb +51 -0
  11. data/lib/decision_agent/dsl/condition_evaluator.rb +133 -0
  12. data/lib/decision_agent/dsl/rule_parser.rb +36 -0
  13. data/lib/decision_agent/dsl/schema_validator.rb +275 -0
  14. data/lib/decision_agent/errors.rb +62 -0
  15. data/lib/decision_agent/evaluation.rb +52 -0
  16. data/lib/decision_agent/evaluators/base.rb +15 -0
  17. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +51 -0
  18. data/lib/decision_agent/evaluators/static_evaluator.rb +31 -0
  19. data/lib/decision_agent/replay/replay.rb +147 -0
  20. data/lib/decision_agent/scoring/base.rb +19 -0
  21. data/lib/decision_agent/scoring/consensus.rb +40 -0
  22. data/lib/decision_agent/scoring/max_weight.rb +16 -0
  23. data/lib/decision_agent/scoring/threshold.rb +40 -0
  24. data/lib/decision_agent/scoring/weighted_average.rb +26 -0
  25. data/lib/decision_agent/version.rb +3 -0
  26. data/lib/decision_agent/web/public/app.js +580 -0
  27. data/lib/decision_agent/web/public/index.html +190 -0
  28. data/lib/decision_agent/web/public/styles.css +558 -0
  29. data/lib/decision_agent/web/server.rb +255 -0
  30. data/lib/decision_agent.rb +29 -0
  31. data/spec/agent_spec.rb +249 -0
  32. data/spec/api_contract_spec.rb +430 -0
  33. data/spec/audit_adapters_spec.rb +74 -0
  34. data/spec/comprehensive_edge_cases_spec.rb +1777 -0
  35. data/spec/context_spec.rb +84 -0
  36. data/spec/dsl_validation_spec.rb +648 -0
  37. data/spec/edge_cases_spec.rb +353 -0
  38. data/spec/examples/feedback_aware_evaluator_spec.rb +460 -0
  39. data/spec/json_rule_evaluator_spec.rb +587 -0
  40. data/spec/replay_edge_cases_spec.rb +699 -0
  41. data/spec/replay_spec.rb +210 -0
  42. data/spec/scoring_spec.rb +225 -0
  43. data/spec/spec_helper.rb +28 -0
  44. 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