decision_agent 0.1.2 → 0.1.4

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 (87) 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/ab_testing/ab_test.rb +197 -0
  5. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
  6. data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
  7. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
  8. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
  9. data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
  10. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
  11. data/lib/decision_agent/agent.rb +19 -26
  12. data/lib/decision_agent/audit/null_adapter.rb +1 -2
  13. data/lib/decision_agent/decision.rb +3 -1
  14. data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
  15. data/lib/decision_agent/dsl/rule_parser.rb +4 -6
  16. data/lib/decision_agent/dsl/schema_validator.rb +27 -31
  17. data/lib/decision_agent/errors.rb +11 -8
  18. data/lib/decision_agent/evaluation.rb +3 -1
  19. data/lib/decision_agent/evaluation_validator.rb +78 -0
  20. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
  21. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
  22. data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
  23. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
  24. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
  25. data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
  26. data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
  27. data/lib/decision_agent/monitoring/metrics_collector.rb +423 -0
  28. data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
  29. data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
  30. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
  31. data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
  32. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
  33. data/lib/decision_agent/replay/replay.rb +12 -22
  34. data/lib/decision_agent/scoring/base.rb +1 -1
  35. data/lib/decision_agent/scoring/consensus.rb +5 -5
  36. data/lib/decision_agent/scoring/weighted_average.rb +1 -1
  37. data/lib/decision_agent/version.rb +1 -1
  38. data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
  39. data/lib/decision_agent/versioning/adapter.rb +1 -3
  40. data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
  41. data/lib/decision_agent/versioning/version_manager.rb +4 -12
  42. data/lib/decision_agent/web/public/index.html +1 -1
  43. data/lib/decision_agent/web/server.rb +19 -24
  44. data/lib/decision_agent.rb +14 -0
  45. data/lib/generators/decision_agent/install/install_generator.rb +42 -5
  46. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
  47. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
  48. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
  49. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
  50. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
  51. data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
  52. data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
  53. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
  54. data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
  55. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
  56. data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
  57. data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
  58. data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
  59. data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
  60. data/spec/ab_testing/ab_test_spec.rb +270 -0
  61. data/spec/activerecord_thread_safety_spec.rb +553 -0
  62. data/spec/agent_spec.rb +13 -13
  63. data/spec/api_contract_spec.rb +16 -16
  64. data/spec/audit_adapters_spec.rb +3 -3
  65. data/spec/comprehensive_edge_cases_spec.rb +86 -86
  66. data/spec/dsl_validation_spec.rb +83 -83
  67. data/spec/edge_cases_spec.rb +23 -23
  68. data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
  69. data/spec/examples.txt +612 -0
  70. data/spec/issue_verification_spec.rb +759 -0
  71. data/spec/json_rule_evaluator_spec.rb +15 -15
  72. data/spec/monitoring/alert_manager_spec.rb +378 -0
  73. data/spec/monitoring/metrics_collector_spec.rb +281 -0
  74. data/spec/monitoring/monitored_agent_spec.rb +222 -0
  75. data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
  76. data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
  77. data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
  78. data/spec/replay_edge_cases_spec.rb +58 -58
  79. data/spec/replay_spec.rb +11 -11
  80. data/spec/rfc8785_canonicalization_spec.rb +215 -0
  81. data/spec/scoring_spec.rb +1 -1
  82. data/spec/spec_helper.rb +9 -0
  83. data/spec/thread_safety_spec.rb +482 -0
  84. data/spec/thread_safety_spec.rb.broken +878 -0
  85. data/spec/versioning_spec.rb +141 -37
  86. data/spec/web_ui_rack_spec.rb +135 -0
  87. metadata +93 -6
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_adapter"
4
+ require "monitor"
5
+
6
+ module DecisionAgent
7
+ module Monitoring
8
+ module Storage
9
+ # In-memory adapter for metrics storage (default, no dependencies)
10
+ class MemoryAdapter < BaseAdapter
11
+ include MonitorMixin
12
+
13
+ def initialize(window_size: 3600)
14
+ super()
15
+ @window_size = window_size
16
+ @metrics = {
17
+ decisions: [],
18
+ evaluations: [],
19
+ performance: [],
20
+ errors: []
21
+ }
22
+ end
23
+
24
+ def record_decision(decision, context, confidence: nil, evaluations_count: 0, duration_ms: nil, status: nil)
25
+ synchronize do
26
+ @metrics[:decisions] << {
27
+ decision: decision,
28
+ context: context,
29
+ confidence: confidence,
30
+ evaluations_count: evaluations_count,
31
+ duration_ms: duration_ms,
32
+ status: status,
33
+ timestamp: Time.now
34
+ }
35
+ cleanup_old_metrics
36
+ end
37
+ end
38
+
39
+ def record_evaluation(evaluator_name, score: nil, success: nil, duration_ms: nil, details: {})
40
+ synchronize do
41
+ @metrics[:evaluations] << {
42
+ evaluator_name: evaluator_name,
43
+ score: score,
44
+ success: success,
45
+ duration_ms: duration_ms,
46
+ details: details,
47
+ timestamp: Time.now
48
+ }
49
+ cleanup_old_metrics
50
+ end
51
+ end
52
+
53
+ def record_performance(operation, duration_ms: nil, status: nil, metadata: {})
54
+ synchronize do
55
+ @metrics[:performance] << {
56
+ operation: operation,
57
+ duration_ms: duration_ms,
58
+ status: status,
59
+ metadata: metadata,
60
+ timestamp: Time.now
61
+ }
62
+ cleanup_old_metrics
63
+ end
64
+ end
65
+
66
+ def record_error(error_type, message: nil, stack_trace: nil, severity: nil, context: {})
67
+ synchronize do
68
+ @metrics[:errors] << {
69
+ error_type: error_type,
70
+ message: message,
71
+ stack_trace: stack_trace,
72
+ severity: severity,
73
+ context: context,
74
+ timestamp: Time.now
75
+ }
76
+ cleanup_old_metrics
77
+ end
78
+ end
79
+
80
+ def statistics(time_range: 3600)
81
+ synchronize do
82
+ cutoff = Time.now - time_range
83
+ recent_decisions = @metrics[:decisions].select { |m| m[:timestamp] >= cutoff }
84
+ recent_evaluations = @metrics[:evaluations].select { |m| m[:timestamp] >= cutoff }
85
+ recent_performance = @metrics[:performance].select { |m| m[:timestamp] >= cutoff }
86
+ recent_errors = @metrics[:errors].select { |m| m[:timestamp] >= cutoff }
87
+
88
+ {
89
+ decisions: decision_statistics(recent_decisions),
90
+ evaluations: evaluation_statistics(recent_evaluations),
91
+ performance: performance_statistics(recent_performance),
92
+ errors: error_statistics(recent_errors)
93
+ }
94
+ end
95
+ end
96
+
97
+ def time_series(metric_type, bucket_size: 60, time_range: 3600)
98
+ synchronize do
99
+ cutoff = Time.now - time_range
100
+ metrics = @metrics[metric_type].select { |m| m[:timestamp] >= cutoff }
101
+
102
+ buckets = Hash.new(0)
103
+ metrics.each do |metric|
104
+ bucket = (metric[:timestamp].to_i / bucket_size) * bucket_size
105
+ buckets[bucket] += 1
106
+ end
107
+
108
+ timestamps = buckets.keys.sort
109
+ {
110
+ timestamps: timestamps.map { |ts| Time.at(ts).iso8601 },
111
+ data: timestamps.map { |ts| buckets[ts] }
112
+ }
113
+ end
114
+ end
115
+
116
+ def metrics_count
117
+ synchronize do
118
+ {
119
+ decisions: @metrics[:decisions].size,
120
+ evaluations: @metrics[:evaluations].size,
121
+ performance: @metrics[:performance].size,
122
+ errors: @metrics[:errors].size
123
+ }
124
+ end
125
+ end
126
+
127
+ def cleanup(older_than:)
128
+ synchronize do
129
+ cutoff = Time.now - older_than
130
+ count = 0
131
+
132
+ @metrics.each_value do |metric_array|
133
+ before_size = metric_array.size
134
+ metric_array.reject! { |m| m[:timestamp] < cutoff }
135
+ count += before_size - metric_array.size
136
+ end
137
+
138
+ count
139
+ end
140
+ end
141
+
142
+ def self.available?
143
+ true # Always available, no dependencies
144
+ end
145
+
146
+ private
147
+
148
+ def cleanup_old_metrics
149
+ cutoff = Time.now - @window_size
150
+ @metrics.each_value do |metric_array|
151
+ metric_array.reject! { |m| m[:timestamp] < cutoff }
152
+ end
153
+ end
154
+
155
+ def decision_statistics(decisions)
156
+ total = decisions.size
157
+ confidences = decisions.map { |d| d[:confidence] }.compact
158
+ statuses = decisions.map { |d| d[:status] }.compact
159
+
160
+ {
161
+ total: total,
162
+ by_decision: decisions.group_by { |d| d[:decision] }.transform_values(&:count),
163
+ average_confidence: confidences.empty? ? 0.0 : confidences.sum / confidences.size.to_f,
164
+ success_rate: calculate_success_rate(statuses)
165
+ }
166
+ end
167
+
168
+ def evaluation_statistics(evaluations)
169
+ total = evaluations.size
170
+ scores = evaluations.map { |e| e[:score] }.compact
171
+
172
+ {
173
+ total: total,
174
+ by_evaluator: evaluations.group_by { |e| e[:evaluator_name] }.transform_values(&:count),
175
+ average_score: scores.empty? ? 0.0 : scores.sum / scores.size.to_f,
176
+ success_rate_by_evaluator: evaluations.select { |e| e[:success] }
177
+ .group_by { |e| e[:evaluator_name] }
178
+ .transform_values(&:count)
179
+ }
180
+ end
181
+
182
+ def performance_statistics(performance_metrics)
183
+ total = performance_metrics.size
184
+ durations = performance_metrics.map { |p| p[:duration_ms] }.compact.sort
185
+ statuses = performance_metrics.map { |p| p[:status] }.compact
186
+
187
+ {
188
+ total: total,
189
+ average_duration_ms: durations.empty? ? 0.0 : durations.sum / durations.size.to_f,
190
+ p50: percentile(durations, 0.50),
191
+ p95: percentile(durations, 0.95),
192
+ p99: percentile(durations, 0.99),
193
+ success_rate: calculate_success_rate(statuses)
194
+ }
195
+ end
196
+
197
+ def error_statistics(errors)
198
+ {
199
+ total: errors.size,
200
+ by_type: errors.group_by { |e| e[:error_type] }.transform_values(&:count),
201
+ by_severity: errors.group_by { |e| e[:severity] }.transform_values(&:count),
202
+ critical_count: errors.count { |e| e[:severity] == "critical" }
203
+ }
204
+ end
205
+
206
+ def percentile(sorted_array, pct)
207
+ return 0.0 if sorted_array.empty?
208
+
209
+ index = ((sorted_array.length - 1) * pct).ceil
210
+ sorted_array[index].to_f
211
+ end
212
+
213
+ def calculate_success_rate(statuses)
214
+ return 0.0 if statuses.empty?
215
+
216
+ successful = statuses.count { |s| s == "success" }
217
+ successful.to_f / statuses.size
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -42,15 +42,11 @@ module DecisionAgent
42
42
  replayed_result
43
43
  end
44
44
 
45
- private
46
-
47
45
  def self.validate_payload!(payload)
48
- required_keys = ["context", "evaluations", "decision", "confidence"]
46
+ required_keys = %w[context evaluations decision confidence]
49
47
 
50
48
  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
49
+ raise InvalidRuleDslError, "Audit payload missing required key: #{key}" unless payload.key?(key) || payload.key?(key.to_sym)
54
50
  end
55
51
  end
56
52
 
@@ -111,30 +107,24 @@ module DecisionAgent
111
107
  end
112
108
 
113
109
  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
110
+ differences << "confidence mismatch (expected: #{original_confidence}, got: #{replayed_confidence})" if conf_diff > 0.0001
117
111
 
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
112
+ return unless differences.any?
113
+
114
+ raise ReplayMismatchError.new(
115
+ expected: { decision: original_decision, confidence: original_confidence },
116
+ actual: { decision: replayed_decision, confidence: replayed_confidence },
117
+ differences: differences
118
+ )
125
119
  end
126
120
 
127
121
  def self.log_differences(original_decision:, original_confidence:, replayed_decision:, replayed_confidence:)
128
122
  differences = []
129
123
 
130
- if original_decision.to_s != replayed_decision.to_s
131
- differences << "Decision changed: #{original_decision} -> #{replayed_decision}"
132
- end
124
+ differences << "Decision changed: #{original_decision} -> #{replayed_decision}" if original_decision.to_s != replayed_decision.to_s
133
125
 
134
126
  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
127
+ differences << "Confidence changed: #{original_confidence} -> #{replayed_confidence}" if conf_diff > 0.0001
138
128
 
139
129
  if differences.any?
140
130
  warn "[DecisionAgent::Replay] Non-strict mode differences detected:"
@@ -12,7 +12,7 @@ module DecisionAgent
12
12
  end
13
13
 
14
14
  def round_confidence(value)
15
- (value * 10000).round / 10000.0
15
+ (value * 10_000).round / 10_000.0
16
16
  end
17
17
  end
18
18
  end
@@ -24,11 +24,11 @@ module DecisionAgent
24
24
 
25
25
  winning_decision, agreement, avg_weight = candidates.first
26
26
 
27
- if agreement >= @minimum_agreement
28
- confidence = agreement * avg_weight
29
- else
30
- confidence = agreement * avg_weight * 0.5
31
- end
27
+ confidence = if agreement >= @minimum_agreement
28
+ agreement * avg_weight
29
+ else
30
+ agreement * avg_weight * 0.5
31
+ end
32
32
 
33
33
  {
34
34
  decision: winning_decision,
@@ -14,7 +14,7 @@ module DecisionAgent
14
14
  winning_decision, winning_weight = weighted_scores.max_by { |_, weight| weight }
15
15
 
16
16
  total_weight = evaluations.sum(&:weight)
17
- confidence = total_weight > 0 ? winning_weight / total_weight : 0.0
17
+ confidence = total_weight.positive? ? winning_weight / total_weight : 0.0
18
18
 
19
19
  {
20
20
  decision: winning_decision,
@@ -1,3 +1,3 @@
1
1
  module DecisionAgent
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.4".freeze
3
3
  end
@@ -1,37 +1,53 @@
1
1
  require_relative "adapter"
2
+ require_relative "file_storage_adapter"
2
3
 
3
4
  module DecisionAgent
4
5
  module Versioning
5
6
  # ActiveRecord-based version storage adapter for Rails applications
6
7
  # Requires ActiveRecord models to be set up in the Rails app
7
8
  class ActiveRecordAdapter < Adapter
9
+ include StatusValidator
10
+
8
11
  def initialize
9
- unless defined?(ActiveRecord)
10
- raise DecisionAgent::ConfigurationError,
11
- "ActiveRecord is not available. Please ensure Rails/ActiveRecord is loaded."
12
- end
12
+ return if defined?(ActiveRecord)
13
+
14
+ raise DecisionAgent::ConfigurationError,
15
+ "ActiveRecord is not available. Please ensure Rails/ActiveRecord is loaded."
13
16
  end
14
17
 
15
18
  def create_version(rule_id:, content:, metadata: {})
16
- # Get the next version number for this rule
17
- last_version = rule_version_class.where(rule_id: rule_id)
18
- .order(version_number: :desc)
19
- .first
20
- next_version_number = last_version ? last_version.version_number + 1 : 1
21
-
22
- # Deactivate previous active versions
23
- rule_version_class.where(rule_id: rule_id, status: "active")
24
- .update_all(status: "archived")
25
-
26
- # Create new version
27
- version = rule_version_class.create!(
28
- rule_id: rule_id,
29
- version_number: next_version_number,
30
- content: content.to_json,
31
- created_by: metadata[:created_by] || "system",
32
- changelog: metadata[:changelog] || "Version #{next_version_number}",
33
- status: metadata[:status] || "active"
34
- )
19
+ # Use a transaction with pessimistic locking to prevent race conditions
20
+ version = nil
21
+
22
+ # Validate status if provided
23
+ status = metadata[:status] || "active"
24
+ validate_status!(status)
25
+
26
+ rule_version_class.transaction do
27
+ # Lock the last version for this rule to prevent concurrent reads
28
+ # This ensures only one thread can calculate the next version number at a time
29
+ last_version = rule_version_class.where(rule_id: rule_id)
30
+ .order(version_number: :desc)
31
+ .lock
32
+ .first
33
+ next_version_number = last_version ? last_version.version_number + 1 : 1
34
+
35
+ # Deactivate previous active versions
36
+ # Use update! instead of update_all to trigger validations
37
+ rule_version_class.where(rule_id: rule_id, status: "active").find_each do |v|
38
+ v.update!(status: "archived")
39
+ end
40
+
41
+ # Create new version
42
+ version = rule_version_class.create!(
43
+ rule_id: rule_id,
44
+ version_number: next_version_number,
45
+ content: content.to_json,
46
+ created_by: metadata[:created_by] || "system",
47
+ changelog: metadata[:changelog] || "Version #{next_version_number}",
48
+ status: status
49
+ )
50
+ end
35
51
 
36
52
  serialize_version(version)
37
53
  end
@@ -63,15 +79,24 @@ module DecisionAgent
63
79
  end
64
80
 
65
81
  def activate_version(version_id:)
66
- version = rule_version_class.find(version_id)
67
-
68
- # Deactivate all other versions for this rule
69
- rule_version_class.where(rule_id: version.rule_id, status: "active")
70
- .where.not(id: version_id)
71
- .update_all(status: "archived")
72
-
73
- # Activate this version
74
- version.update!(status: "active")
82
+ version = nil
83
+
84
+ rule_version_class.transaction do
85
+ # Find and lock the version to activate
86
+ version = rule_version_class.lock.find(version_id)
87
+
88
+ # Deactivate all other versions for this rule within the same transaction
89
+ # The lock ensures only one thread can perform this operation at a time
90
+ # Use update! instead of update_all to trigger validations
91
+ rule_version_class.where(rule_id: version.rule_id, status: "active")
92
+ .where.not(id: version_id)
93
+ .find_each do |v|
94
+ v.update!(status: "archived")
95
+ end
96
+
97
+ # Activate this version
98
+ version.update!(status: "active")
99
+ end
75
100
 
76
101
  serialize_version(version)
77
102
  end
@@ -89,11 +114,22 @@ module DecisionAgent
89
114
  end
90
115
 
91
116
  def serialize_version(version)
117
+ # Parse JSON content with proper error handling
118
+ parsed_content = begin
119
+ JSON.parse(version.content)
120
+ rescue JSON::ParserError => e
121
+ raise DecisionAgent::ValidationError,
122
+ "Invalid JSON in version #{version.id} for rule #{version.rule_id}: #{e.message}"
123
+ rescue TypeError, NoMethodError
124
+ raise DecisionAgent::ValidationError,
125
+ "Invalid content in version #{version.id} for rule #{version.rule_id}: content is nil or not a string"
126
+ end
127
+
92
128
  {
93
129
  id: version.id,
94
130
  rule_id: version.rule_id,
95
131
  version_number: version.version_number,
96
- content: JSON.parse(version.content),
132
+ content: parsed_content,
97
133
  created_by: version.created_by,
98
134
  created_at: version.created_at,
99
135
  changelog: version.changelog,
@@ -91,9 +91,7 @@ module DecisionAgent
91
91
  changes = {}
92
92
  hash1.each do |key, value1|
93
93
  value2 = hash2[key]
94
- if value1 != value2 && !value2.nil?
95
- changes[key] = { old: value1, new: value2 }
96
- end
94
+ changes[key] = { old: value1, new: value2 } if value1 != value2 && !value2.nil?
97
95
  end
98
96
  changes
99
97
  end