decision_agent 0.1.2 → 0.1.3

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +212 -35
  3. data/bin/decision_agent +3 -8
  4. data/lib/decision_agent/agent.rb +19 -26
  5. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  6. data/lib/decision_agent/decision.rb +3 -1
  7. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  8. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  9. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  10. data/lib/decision_agent/errors.rb +11 -8
  11. data/lib/decision_agent/evaluation.rb +3 -1
  12. data/lib/decision_agent/evaluation_validator.rb +78 -0
  13. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  14. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  15. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  16. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  17. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  18. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  19. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  20. data/lib/decision_agent/monitoring/metrics_collector.rb +278 -0
  21. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  22. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  23. data/lib/decision_agent/replay/replay.rb +12 -22
  24. data/lib/decision_agent/scoring/base.rb +1 -1
  25. data/lib/decision_agent/scoring/consensus.rb +5 -5
  26. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  27. data/lib/decision_agent/version.rb +1 -1
  28. data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
  29. data/lib/decision_agent/versioning/adapter.rb +1 -3
  30. data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
  31. data/lib/decision_agent/versioning/version_manager.rb +4 -12
  32. data/lib/decision_agent/web/public/index.html +1 -1
  33. data/lib/decision_agent/web/server.rb +19 -24
  34. data/lib/decision_agent.rb +7 -0
  35. data/lib/generators/decision_agent/install/install_generator.rb +5 -5
  36. data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
  37. data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
  38. data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
  39. data/spec/activerecord_thread_safety_spec.rb +553 -0
  40. data/spec/agent_spec.rb +13 -13
  41. data/spec/api_contract_spec.rb +16 -16
  42. data/spec/audit_adapters_spec.rb +3 -3
  43. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  44. data/spec/dsl_validation_spec.rb +83 -83
  45. data/spec/edge_cases_spec.rb +23 -23
  46. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  47. data/spec/examples.txt +548 -0
  48. data/spec/issue_verification_spec.rb +685 -0
  49. data/spec/json_rule_evaluator_spec.rb +15 -15
  50. data/spec/monitoring/alert_manager_spec.rb +378 -0
  51. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  52. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  53. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  54. data/spec/replay_edge_cases_spec.rb +58 -58
  55. data/spec/replay_spec.rb +11 -11
  56. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  57. data/spec/scoring_spec.rb +1 -1
  58. data/spec/spec_helper.rb +9 -0
  59. data/spec/thread_safety_spec.rb +482 -0
  60. data/spec/thread_safety_spec.rb.broken +878 -0
  61. data/spec/versioning_spec.rb +141 -37
  62. data/spec/web_ui_rack_spec.rb +135 -0
  63. metadata +69 -6
@@ -3,13 +3,13 @@ module DecisionAgent
3
3
 
4
4
  class InvalidRuleDslError < Error
5
5
  def initialize(message = "Invalid rule DSL structure")
6
- super(message)
6
+ super
7
7
  end
8
8
  end
9
9
 
10
10
  class NoEvaluationsError < Error
11
11
  def initialize(message = "No evaluators returned a decision")
12
- super(message)
12
+ super
13
13
  end
14
14
  end
15
15
 
@@ -26,25 +26,25 @@ module DecisionAgent
26
26
 
27
27
  class InvalidConfigurationError < Error
28
28
  def initialize(message = "Invalid agent configuration")
29
- super(message)
29
+ super
30
30
  end
31
31
  end
32
32
 
33
33
  class InvalidEvaluatorError < Error
34
34
  def initialize(message = "Evaluator must respond to #evaluate")
35
- super(message)
35
+ super
36
36
  end
37
37
  end
38
38
 
39
39
  class InvalidScoringStrategyError < Error
40
40
  def initialize(message = "Scoring strategy must respond to #score")
41
- super(message)
41
+ super
42
42
  end
43
43
  end
44
44
 
45
45
  class InvalidAuditAdapterError < Error
46
46
  def initialize(message = "Audit adapter must respond to #record")
47
- super(message)
47
+ super
48
48
  end
49
49
  end
50
50
 
@@ -62,13 +62,16 @@ module DecisionAgent
62
62
 
63
63
  class NotFoundError < Error
64
64
  def initialize(message = "Resource not found")
65
- super(message)
65
+ super
66
66
  end
67
67
  end
68
68
 
69
69
  class ValidationError < Error
70
70
  def initialize(message = "Validation failed")
71
- super(message)
71
+ super
72
72
  end
73
73
  end
74
+
75
+ # Alias for backward compatibility and clearer naming
76
+ ConfigurationError = InvalidConfigurationError
74
77
  end
@@ -10,6 +10,8 @@ module DecisionAgent
10
10
  @reason = reason.to_s.freeze
11
11
  @evaluator_name = evaluator_name.to_s.freeze
12
12
  @metadata = deep_freeze(metadata)
13
+
14
+ freeze
13
15
  end
14
16
 
15
17
  def to_h
@@ -35,7 +37,7 @@ module DecisionAgent
35
37
 
36
38
  def validate_weight!(weight)
37
39
  w = weight.to_f
38
- raise InvalidWeightError.new(weight) unless w >= 0.0 && w <= 1.0
40
+ raise InvalidWeightError, weight unless w.between?(0.0, 1.0)
39
41
  end
40
42
 
41
43
  def deep_freeze(obj)
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DecisionAgent
4
+ # Validates evaluation objects for correctness and thread-safety
5
+ class EvaluationValidator
6
+ class ValidationError < StandardError; end
7
+
8
+ # Validates a single evaluation
9
+ # @param evaluation [Evaluation] the evaluation to validate
10
+ # @raise [ValidationError] if validation fails
11
+ def self.validate!(evaluation)
12
+ raise ValidationError, "Evaluation cannot be nil" if evaluation.nil?
13
+ raise ValidationError, "Evaluation must be an Evaluation instance" unless evaluation.is_a?(Evaluation)
14
+
15
+ validate_decision!(evaluation.decision)
16
+ validate_weight!(evaluation.weight)
17
+ validate_reason!(evaluation.reason)
18
+ validate_evaluator_name!(evaluation.evaluator_name)
19
+ validate_frozen!(evaluation)
20
+
21
+ true
22
+ end
23
+
24
+ # Validates an array of evaluations
25
+ # @param evaluations [Array<Evaluation>] the evaluations to validate
26
+ # @raise [ValidationError] if validation fails
27
+ def self.validate_all!(evaluations)
28
+ raise ValidationError, "Evaluations must be an Array" unless evaluations.is_a?(Array)
29
+ raise ValidationError, "Evaluations array cannot be empty" if evaluations.empty?
30
+
31
+ evaluations.each_with_index do |evaluation, index|
32
+ validate!(evaluation)
33
+ rescue ValidationError => e
34
+ raise ValidationError, "Validation failed for evaluation at index #{index}: #{e.message}"
35
+ end
36
+
37
+ true
38
+ end
39
+
40
+ private_class_method def self.validate_decision!(decision)
41
+ raise ValidationError, "Decision cannot be nil" if decision.nil?
42
+ raise ValidationError, "Decision must be a String" unless decision.is_a?(String)
43
+ raise ValidationError, "Decision cannot be empty" if decision.strip.empty?
44
+ end
45
+
46
+ private_class_method def self.validate_weight!(weight)
47
+ raise ValidationError, "Weight cannot be nil" if weight.nil?
48
+ raise ValidationError, "Weight must be a Numeric" unless weight.is_a?(Numeric)
49
+ raise ValidationError, "Weight must be between 0 and 1" unless weight.between?(0, 1)
50
+ end
51
+
52
+ private_class_method def self.validate_reason!(reason)
53
+ raise ValidationError, "Reason cannot be nil" if reason.nil?
54
+ raise ValidationError, "Reason must be a String" unless reason.is_a?(String)
55
+ raise ValidationError, "Reason cannot be empty" if reason.strip.empty?
56
+ end
57
+
58
+ private_class_method def self.validate_evaluator_name!(name)
59
+ raise ValidationError, "Evaluator name cannot be nil" if name.nil?
60
+ raise ValidationError, "Evaluator name must be a String or Symbol" unless name.is_a?(String) || name.is_a?(Symbol)
61
+ end
62
+
63
+ private_class_method def self.validate_frozen!(evaluation)
64
+ raise ValidationError, "Evaluation must be frozen for thread-safety (call .freeze)" unless evaluation.frozen?
65
+
66
+ # Verify nested structures are also frozen
67
+ raise ValidationError, "Evaluation decision must be frozen" unless evaluation.decision.frozen?
68
+
69
+ raise ValidationError, "Evaluation reason must be frozen" unless evaluation.reason.frozen?
70
+
71
+ raise ValidationError, "Evaluation evaluator_name must be frozen" unless evaluation.evaluator_name.frozen?
72
+
73
+ return unless evaluation.metadata && !evaluation.metadata.frozen?
74
+
75
+ raise ValidationError, "Evaluation metadata must be frozen"
76
+ end
77
+ end
78
+ end
@@ -10,6 +10,12 @@ module DecisionAgent
10
10
  @ruleset = Dsl::RuleParser.parse(@rules_json)
11
11
  @ruleset_name = @ruleset["ruleset"] || "unknown"
12
12
  @name = name || "JsonRuleEvaluator(#{@ruleset_name})"
13
+
14
+ # Freeze ruleset to ensure thread-safety
15
+ deep_freeze(@ruleset)
16
+ @rules_json.freeze
17
+ @ruleset_name.freeze
18
+ @name.freeze
13
19
  end
14
20
 
15
21
  def evaluate(context, feedback: {})
@@ -46,6 +52,26 @@ module DecisionAgent
46
52
  Dsl::ConditionEvaluator.evaluate(if_clause, context)
47
53
  end
48
54
  end
55
+
56
+ # Deep freeze helper method
57
+ def deep_freeze(obj)
58
+ case obj
59
+ when Hash
60
+ obj.each do |k, v|
61
+ deep_freeze(k)
62
+ deep_freeze(v)
63
+ end
64
+ obj.freeze
65
+ when Array
66
+ obj.each { |item| deep_freeze(item) }
67
+ obj.freeze
68
+ when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
69
+ obj.freeze
70
+ else
71
+ obj.freeze if obj.respond_to?(:freeze)
72
+ end
73
+ obj
74
+ end
49
75
  end
50
76
  end
51
77
  end
@@ -11,12 +11,8 @@ module DecisionAgent
11
11
  @custom_metadata = metadata
12
12
  end
13
13
 
14
- def evaluate(context, feedback: {})
15
- metadata = if @custom_metadata
16
- @custom_metadata
17
- else
18
- { type: "static" }
19
- end
14
+ def evaluate(_context, feedback: {})
15
+ metadata = @custom_metadata || { type: "static" }
20
16
 
21
17
  Evaluation.new(
22
18
  decision: @decision,
@@ -0,0 +1,282 @@
1
+ require "monitor"
2
+
3
+ module DecisionAgent
4
+ module Monitoring
5
+ # Alert manager for anomaly detection and notifications
6
+ class AlertManager
7
+ include MonitorMixin
8
+
9
+ attr_reader :rules, :alerts
10
+
11
+ def initialize(metrics_collector:)
12
+ super()
13
+ @metrics_collector = metrics_collector
14
+ @rules = []
15
+ @alerts = []
16
+ @alert_handlers = []
17
+ @check_interval = 60 # seconds
18
+ @monitoring_thread = nil
19
+ freeze_config
20
+ end
21
+
22
+ # Define an alert rule
23
+ def add_rule(name:, condition:, severity: :warning, threshold: nil, message: nil, cooldown: 300)
24
+ synchronize do
25
+ rule = {
26
+ id: generate_rule_id(name),
27
+ name: name,
28
+ condition: condition,
29
+ severity: severity,
30
+ threshold: threshold,
31
+ message: message || "Alert: #{name}",
32
+ cooldown: cooldown,
33
+ last_triggered: nil,
34
+ enabled: true
35
+ }
36
+
37
+ @rules << rule
38
+ rule
39
+ end
40
+ end
41
+
42
+ # Remove a rule
43
+ def remove_rule(rule_id)
44
+ synchronize do
45
+ @rules.reject! { |r| r[:id] == rule_id }
46
+ end
47
+ end
48
+
49
+ # Enable/disable rule
50
+ def toggle_rule(rule_id, enabled)
51
+ synchronize do
52
+ rule = @rules.find { |r| r[:id] == rule_id }
53
+ rule[:enabled] = enabled if rule
54
+ end
55
+ end
56
+
57
+ # Register alert handler
58
+ def add_handler(&block)
59
+ synchronize do
60
+ @alert_handlers << block
61
+ end
62
+ end
63
+
64
+ # Start monitoring
65
+ def start_monitoring(interval: 60)
66
+ synchronize do
67
+ return if @monitoring_thread&.alive?
68
+
69
+ @check_interval = interval
70
+ @monitoring_thread = Thread.new do
71
+ loop do
72
+ check_rules
73
+ sleep @check_interval
74
+ rescue StandardError => e
75
+ warn "Alert monitoring error: #{e.message}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ # Stop monitoring
82
+ def stop_monitoring
83
+ synchronize do
84
+ @monitoring_thread&.kill
85
+ @monitoring_thread = nil
86
+ end
87
+ end
88
+
89
+ # Manually check all rules
90
+ def check_rules
91
+ stats = @metrics_collector.statistics
92
+
93
+ @rules.each do |rule|
94
+ next unless rule[:enabled]
95
+ next if in_cooldown?(rule)
96
+
97
+ trigger_alert(rule, stats) if evaluate_condition(rule[:condition], stats)
98
+ end
99
+ end
100
+
101
+ # Get active alerts
102
+ def active_alerts
103
+ synchronize do
104
+ @alerts.select { |a| a[:status] == :active }
105
+ end
106
+ end
107
+
108
+ # Get all alerts
109
+ def all_alerts(limit: 100)
110
+ synchronize do
111
+ @alerts.last(limit)
112
+ end
113
+ end
114
+
115
+ # Acknowledge alert
116
+ def acknowledge_alert(alert_id, acknowledged_by: "system")
117
+ synchronize do
118
+ alert = @alerts.find { |a| a[:id] == alert_id }
119
+ if alert
120
+ alert[:status] = :acknowledged
121
+ alert[:acknowledged_by] = acknowledged_by
122
+ alert[:acknowledged_at] = Time.now.utc
123
+ end
124
+ end
125
+ end
126
+
127
+ # Resolve alert
128
+ def resolve_alert(alert_id, resolved_by: "system")
129
+ synchronize do
130
+ alert = @alerts.find { |a| a[:id] == alert_id }
131
+ if alert
132
+ alert[:status] = :resolved
133
+ alert[:resolved_by] = resolved_by
134
+ alert[:resolved_at] = Time.now.utc
135
+ end
136
+ end
137
+ end
138
+
139
+ # Clear old alerts
140
+ def clear_old_alerts(older_than: 86_400)
141
+ synchronize do
142
+ cutoff = Time.now.utc - older_than
143
+ @alerts.reject! { |a| a[:triggered_at] < cutoff && a[:status] != :active }
144
+ end
145
+ end
146
+
147
+ # Built-in alert conditions
148
+ def self.high_error_rate(threshold: 0.1)
149
+ lambda do |stats|
150
+ total_ops = stats.dig(:performance, :total_operations) || 0
151
+ return false if total_ops.zero?
152
+
153
+ success_rate = stats.dig(:performance, :success_rate) || 1.0
154
+ (1.0 - success_rate) > threshold
155
+ end
156
+ end
157
+
158
+ def self.low_confidence(threshold: 0.5)
159
+ lambda do |stats|
160
+ avg_confidence = stats.dig(:decisions, :avg_confidence)
161
+ avg_confidence && avg_confidence < threshold
162
+ end
163
+ end
164
+
165
+ def self.high_latency(threshold_ms: 1000)
166
+ lambda do |stats|
167
+ p95 = stats.dig(:performance, :p95_duration_ms)
168
+ p95 && p95 > threshold_ms
169
+ end
170
+ end
171
+
172
+ def self.error_spike(threshold: 10, time_window: 300)
173
+ lambda do |stats|
174
+ recent_errors = stats.dig(:errors, :total) || 0
175
+ recent_errors > threshold
176
+ end
177
+ end
178
+
179
+ def self.decision_anomaly(expected_rate: 100, variance: 0.3)
180
+ lambda do |stats|
181
+ total = stats.dig(:decisions, :total) || 0
182
+ time_range = stats.dig(:summary, :time_range)
183
+
184
+ # Simple anomaly detection based on rate
185
+ return false unless time_range
186
+
187
+ lower_bound = expected_rate * (1 - variance)
188
+ upper_bound = expected_rate * (1 + variance)
189
+
190
+ total < lower_bound || total > upper_bound
191
+ end
192
+ end
193
+
194
+ private
195
+
196
+ def freeze_config
197
+ # No immutable config to freeze yet
198
+ end
199
+
200
+ def generate_rule_id(name)
201
+ "#{sanitize_name(name)}_#{Time.now.to_i}_#{rand(1000)}"
202
+ end
203
+
204
+ def sanitize_name(name)
205
+ name.to_s.downcase.gsub(/[^a-z0-9]/, "_")
206
+ end
207
+
208
+ def in_cooldown?(rule)
209
+ return false unless rule[:last_triggered]
210
+
211
+ Time.now.utc - rule[:last_triggered] < rule[:cooldown]
212
+ end
213
+
214
+ def evaluate_condition(condition, stats)
215
+ case condition
216
+ when Proc
217
+ condition.call(stats)
218
+ when Hash
219
+ evaluate_hash_condition(condition, stats)
220
+ else
221
+ false
222
+ end
223
+ end
224
+
225
+ def evaluate_hash_condition(condition, stats)
226
+ # Support simple hash-based conditions
227
+ # Example: { metric: "decisions.avg_confidence", op: "lt", value: 0.5 }
228
+ metric_path = condition[:metric]&.split(".")
229
+ return false unless metric_path
230
+
231
+ value = stats.dig(*metric_path.map(&:to_sym))
232
+ return false if value.nil?
233
+
234
+ case condition[:op]
235
+ when "gt", ">"
236
+ value > condition[:value]
237
+ when "lt", "<"
238
+ value < condition[:value]
239
+ when "eq", "=="
240
+ value == condition[:value]
241
+ when "gte", ">="
242
+ value >= condition[:value]
243
+ when "lte", "<="
244
+ value <= condition[:value]
245
+ else
246
+ false
247
+ end
248
+ end
249
+
250
+ def trigger_alert(rule, stats)
251
+ alert = {
252
+ id: "alert_#{Time.now.to_i}_#{rand(10_000)}",
253
+ rule_id: rule[:id],
254
+ rule_name: rule[:name],
255
+ severity: rule[:severity],
256
+ message: rule[:message],
257
+ triggered_at: Time.now.utc,
258
+ status: :active,
259
+ context: {
260
+ stats_snapshot: stats
261
+ }
262
+ }
263
+
264
+ @alerts << alert
265
+ rule[:last_triggered] = Time.now.utc
266
+
267
+ # Notify handlers
268
+ notify_handlers(alert)
269
+
270
+ alert
271
+ end
272
+
273
+ def notify_handlers(alert)
274
+ @alert_handlers.each do |handler|
275
+ handler.call(alert)
276
+ rescue StandardError => e
277
+ warn "Alert handler failed: #{e.message}"
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end