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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PerformanceMetric < ApplicationRecord
4
+ validates :operation, presence: true
5
+ validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
6
+ validates :status, inclusion: { in: %w[success failure error] }, allow_nil: true
7
+
8
+ scope :recent, ->(time_range = 3600) { where("created_at >= ?", Time.now - time_range) }
9
+ scope :by_operation, ->(operation) { where(operation: operation) }
10
+ scope :successful, -> { where(status: "success") }
11
+ scope :failed, -> { where(status: "failure") }
12
+ scope :with_errors, -> { where(status: "error") }
13
+ scope :slow, ->(threshold_ms = 1000) { where("duration_ms > ?", threshold_ms) }
14
+
15
+ # Performance statistics
16
+ def self.average_duration(time_range: 3600)
17
+ recent(time_range).where.not(duration_ms: nil).average(:duration_ms).to_f
18
+ end
19
+
20
+ def self.percentile(pct, time_range: 3600)
21
+ durations = recent(time_range).where.not(duration_ms: nil).order(:duration_ms).pluck(:duration_ms)
22
+ return 0.0 if durations.empty?
23
+
24
+ index = ((durations.length - 1) * pct).ceil
25
+ durations[index].to_f
26
+ end
27
+
28
+ def self.p50(time_range: 3600)
29
+ percentile(0.50, time_range: time_range)
30
+ end
31
+
32
+ def self.p95(time_range: 3600)
33
+ percentile(0.95, time_range: time_range)
34
+ end
35
+
36
+ def self.p99(time_range: 3600)
37
+ percentile(0.99, time_range: time_range)
38
+ end
39
+
40
+ def self.max_duration(time_range: 3600)
41
+ recent(time_range).maximum(:duration_ms).to_f
42
+ end
43
+
44
+ def self.min_duration(time_range: 3600)
45
+ recent(time_range).minimum(:duration_ms).to_f
46
+ end
47
+
48
+ def self.success_rate(time_range: 3600)
49
+ total = recent(time_range).where.not(status: nil).count
50
+ return 0.0 if total.zero?
51
+
52
+ successful_count = recent(time_range).successful.count
53
+ successful_count.to_f / total
54
+ end
55
+
56
+ def self.count_by_operation(time_range: 3600)
57
+ recent(time_range).group(:operation).count
58
+ end
59
+
60
+ # Time series aggregation
61
+ def self.average_duration_by_time(bucket_size: 60, time_range: 3600)
62
+ recent(time_range)
63
+ .where.not(duration_ms: nil)
64
+ .group("(EXTRACT(EPOCH FROM created_at)::bigint / #{bucket_size}) * #{bucket_size}")
65
+ .average(:duration_ms)
66
+ end
67
+
68
+ # Parse JSON metadata field
69
+ def parsed_metadata
70
+ return {} if metadata.nil?
71
+
72
+ JSON.parse(metadata, symbolize_names: true)
73
+ rescue JSON::ParserError
74
+ {}
75
+ end
76
+ end
@@ -5,12 +5,12 @@ class Rule < ApplicationRecord
5
5
  validates :ruleset, presence: true
6
6
  validates :status, inclusion: { in: %w[active inactive archived] }
7
7
 
8
- scope :active, -> { where(status: 'active') }
8
+ scope :active, -> { where(status: "active") }
9
9
  scope :by_ruleset, ->(ruleset) { where(ruleset: ruleset) }
10
10
 
11
11
  # Get the active version for this rule
12
12
  def active_version
13
- rule_versions.find_by(status: 'active')
13
+ rule_versions.find_by(status: "active")
14
14
  end
15
15
 
16
16
  # Get all versions ordered by version number
@@ -19,7 +19,7 @@ class Rule < ApplicationRecord
19
19
  end
20
20
 
21
21
  # Create a new version
22
- def create_version(content:, created_by: 'system', changelog: nil)
22
+ def create_version(content:, created_by: "system", changelog: nil)
23
23
  DecisionAgent::Versioning::VersionManager.new.save_version(
24
24
  rule_id: rule_id,
25
25
  rule_content: content,
@@ -7,7 +7,7 @@ class RuleVersion < ApplicationRecord
7
7
  validates :status, inclusion: { in: %w[draft active archived] }
8
8
  validates :created_by, presence: true
9
9
 
10
- scope :active, -> { where(status: 'active') }
10
+ scope :active, -> { where(status: "active") }
11
11
  scope :for_rule, ->(rule_id) { where(rule_id: rule_id).order(version_number: :desc) }
12
12
  scope :latest, -> { order(version_number: :desc).limit(1) }
13
13
 
@@ -29,12 +29,15 @@ class RuleVersion < ApplicationRecord
29
29
  def activate!
30
30
  transaction do
31
31
  # Deactivate all other versions for this rule
32
- self.class.where(rule_id: rule_id, status: 'active')
33
- .where.not(id: id)
34
- .update_all(status: 'archived')
32
+ # Use update! instead of update_all to trigger validations
33
+ self.class.where(rule_id: rule_id, status: "active")
34
+ .where.not(id: id)
35
+ .find_each do |v|
36
+ v.update!(status: "archived")
37
+ end
35
38
 
36
39
  # Activate this version
37
- update!(status: 'active')
40
+ update!(status: "active")
38
41
  end
39
42
  end
40
43
 
@@ -51,9 +54,12 @@ class RuleVersion < ApplicationRecord
51
54
  def set_next_version_number
52
55
  return if version_number.present?
53
56
 
57
+ # Use pessimistic locking to prevent race conditions when calculating version numbers
58
+ # Lock the last version record to ensure only one thread can read and increment at a time
54
59
  last_version = self.class.where(rule_id: rule_id)
55
- .order(version_number: :desc)
56
- .first
60
+ .order(version_number: :desc)
61
+ .lock
62
+ .first
57
63
 
58
64
  self.version_number = last_version ? last_version.version_number + 1 : 1
59
65
  end
@@ -0,0 +1,330 @@
1
+ require "spec_helper"
2
+ require "decision_agent/ab_testing/ab_test_manager"
3
+ require "decision_agent/ab_testing/storage/memory_adapter"
4
+ require "decision_agent/versioning/file_storage_adapter"
5
+
6
+ RSpec.describe DecisionAgent::ABTesting::ABTestManager do
7
+ let(:version_manager) do
8
+ DecisionAgent::Versioning::VersionManager.new(
9
+ adapter: DecisionAgent::Versioning::FileStorageAdapter.new(storage_path: "/tmp/spec_ab_test_versions")
10
+ )
11
+ end
12
+
13
+ let(:storage_adapter) { DecisionAgent::ABTesting::Storage::MemoryAdapter.new }
14
+ let(:manager) { described_class.new(storage_adapter: storage_adapter, version_manager: version_manager) }
15
+
16
+ before do
17
+ # Create test versions
18
+ @champion = version_manager.save_version(
19
+ rule_id: "test_rule",
20
+ rule_content: { rules: [{ decision: "approve", weight: 1.0 }] },
21
+ created_by: "spec"
22
+ )
23
+
24
+ @challenger = version_manager.save_version(
25
+ rule_id: "test_rule",
26
+ rule_content: { rules: [{ decision: "reject", weight: 1.0 }] },
27
+ created_by: "spec"
28
+ )
29
+ end
30
+
31
+ after do
32
+ FileUtils.rm_rf("/tmp/spec_ab_test_versions")
33
+ end
34
+
35
+ describe "#create_test" do
36
+ it "creates a new A/B test" do
37
+ test = manager.create_test(
38
+ name: "Test A vs B",
39
+ champion_version_id: @champion[:id],
40
+ challenger_version_id: @challenger[:id]
41
+ )
42
+
43
+ expect(test).to be_a(DecisionAgent::ABTesting::ABTest)
44
+ expect(test.name).to eq("Test A vs B")
45
+ expect(test.id).not_to be_nil
46
+ end
47
+
48
+ it "validates that champion version exists" do
49
+ expect do
50
+ manager.create_test(
51
+ name: "Test",
52
+ champion_version_id: "nonexistent",
53
+ challenger_version_id: @challenger[:id]
54
+ )
55
+ end.to raise_error(DecisionAgent::ABTesting::VersionNotFoundError, /Champion/)
56
+ end
57
+
58
+ it "validates that challenger version exists" do
59
+ expect do
60
+ manager.create_test(
61
+ name: "Test",
62
+ champion_version_id: @champion[:id],
63
+ challenger_version_id: "nonexistent"
64
+ )
65
+ end.to raise_error(DecisionAgent::ABTesting::VersionNotFoundError, /Challenger/)
66
+ end
67
+
68
+ it "accepts custom traffic split" do
69
+ test = manager.create_test(
70
+ name: "Custom Split",
71
+ champion_version_id: @champion[:id],
72
+ challenger_version_id: @challenger[:id],
73
+ traffic_split: { champion: 70, challenger: 30 }
74
+ )
75
+
76
+ expect(test.traffic_split).to eq({ champion: 70, challenger: 30 })
77
+ end
78
+ end
79
+
80
+ describe "#get_test" do
81
+ it "retrieves a test by ID" do
82
+ created_test = manager.create_test(
83
+ name: "Test",
84
+ champion_version_id: @champion[:id],
85
+ challenger_version_id: @challenger[:id]
86
+ )
87
+
88
+ retrieved_test = manager.get_test(created_test.id)
89
+
90
+ expect(retrieved_test).not_to be_nil
91
+ expect(retrieved_test.id).to eq(created_test.id)
92
+ expect(retrieved_test.name).to eq("Test")
93
+ end
94
+
95
+ it "returns nil for nonexistent test" do
96
+ test = manager.get_test(99_999)
97
+ expect(test).to be_nil
98
+ end
99
+ end
100
+
101
+ describe "#assign_variant" do
102
+ let(:test) do
103
+ manager.create_test(
104
+ name: "Test",
105
+ champion_version_id: @champion[:id],
106
+ challenger_version_id: @challenger[:id],
107
+ start_date: Time.now.utc + 3600
108
+ )
109
+ end
110
+
111
+ before do
112
+ manager.start_test(test.id)
113
+ end
114
+
115
+ it "assigns a variant and returns assignment details" do
116
+ assignment = manager.assign_variant(test_id: test.id, user_id: "user_123")
117
+
118
+ expect(assignment[:test_id]).to eq(test.id)
119
+ expect(%i[champion challenger]).to include(assignment[:variant])
120
+ expect([@champion[:id], @challenger[:id]]).to include(assignment[:version_id])
121
+ expect(assignment[:assignment_id]).not_to be_nil
122
+ end
123
+
124
+ it "assigns same variant to same user" do
125
+ user_id = "consistent_user"
126
+ assignment1 = manager.assign_variant(test_id: test.id, user_id: user_id)
127
+ assignment2 = manager.assign_variant(test_id: test.id, user_id: user_id)
128
+
129
+ expect(assignment1[:variant]).to eq(assignment2[:variant])
130
+ end
131
+
132
+ it "raises error for nonexistent test" do
133
+ expect do
134
+ manager.assign_variant(test_id: 99_999)
135
+ end.to raise_error(DecisionAgent::ABTesting::TestNotFoundError)
136
+ end
137
+ end
138
+
139
+ describe "#record_decision" do
140
+ let(:test) do
141
+ test = manager.create_test(
142
+ name: "Test",
143
+ champion_version_id: @champion[:id],
144
+ challenger_version_id: @challenger[:id],
145
+ start_date: Time.now.utc + 3600
146
+ )
147
+ manager.start_test(test.id)
148
+ test
149
+ end
150
+
151
+ it "records decision result for an assignment" do
152
+ assignment = manager.assign_variant(test_id: test.id)
153
+
154
+ expect do
155
+ manager.record_decision(
156
+ assignment_id: assignment[:assignment_id],
157
+ decision: "approve",
158
+ confidence: 0.95
159
+ )
160
+ end.not_to raise_error
161
+ end
162
+ end
163
+
164
+ describe "#get_results" do
165
+ let(:test) do
166
+ test = manager.create_test(
167
+ name: "Test",
168
+ champion_version_id: @champion[:id],
169
+ challenger_version_id: @challenger[:id],
170
+ start_date: Time.now.utc + 3600
171
+ )
172
+ manager.start_test(test.id)
173
+ test
174
+ end
175
+
176
+ it "returns results with statistics" do
177
+ # Create some assignments and record decisions
178
+ 10.times do |i|
179
+ assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
180
+ manager.record_decision(
181
+ assignment_id: assignment[:assignment_id],
182
+ decision: "approve",
183
+ confidence: 0.8 + (rand * 0.2)
184
+ )
185
+ end
186
+
187
+ results = manager.get_results(test.id)
188
+
189
+ expect(results[:test]).to be_a(Hash)
190
+ expect(results[:champion]).to be_a(Hash)
191
+ expect(results[:challenger]).to be_a(Hash)
192
+ expect(results[:comparison]).to be_a(Hash)
193
+ expect(results[:total_assignments]).to eq(10)
194
+ end
195
+
196
+ it "handles tests with no assignments" do
197
+ results = manager.get_results(test.id)
198
+
199
+ expect(results[:total_assignments]).to eq(0)
200
+ expect(results[:champion][:decisions_recorded]).to eq(0)
201
+ expect(results[:challenger][:decisions_recorded]).to eq(0)
202
+ end
203
+ end
204
+
205
+ describe "#active_tests" do
206
+ it "returns only running tests" do
207
+ test1 = manager.create_test(
208
+ name: "Running Test",
209
+ champion_version_id: @champion[:id],
210
+ challenger_version_id: @challenger[:id],
211
+ start_date: Time.now.utc + 3600
212
+ )
213
+ manager.start_test(test1.id)
214
+
215
+ manager.create_test(
216
+ name: "Scheduled Test",
217
+ champion_version_id: @champion[:id],
218
+ challenger_version_id: @challenger[:id],
219
+ start_date: Time.now.utc + 3600
220
+ )
221
+
222
+ active = manager.active_tests
223
+
224
+ expect(active.size).to eq(1)
225
+ expect(active.first.id).to eq(test1.id)
226
+ end
227
+
228
+ it "caches active tests for performance" do
229
+ test = manager.create_test(
230
+ name: "Test",
231
+ champion_version_id: @champion[:id],
232
+ challenger_version_id: @challenger[:id],
233
+ start_date: Time.now.utc + 3600
234
+ )
235
+ manager.start_test(test.id)
236
+
237
+ # First call
238
+ manager.active_tests
239
+
240
+ # Expect storage adapter not to be called again (cached)
241
+ expect(storage_adapter).not_to receive(:list_tests)
242
+ manager.active_tests
243
+ end
244
+ end
245
+
246
+ describe "test lifecycle" do
247
+ let(:test) do
248
+ manager.create_test(
249
+ name: "Test",
250
+ champion_version_id: @champion[:id],
251
+ challenger_version_id: @challenger[:id],
252
+ start_date: Time.now.utc + 3600
253
+ )
254
+ end
255
+
256
+ it "starts a scheduled test" do
257
+ manager.start_test(test.id)
258
+ updated_test = manager.get_test(test.id)
259
+
260
+ expect(updated_test.status).to eq("running")
261
+ end
262
+
263
+ it "completes a running test" do
264
+ manager.start_test(test.id)
265
+ manager.complete_test(test.id)
266
+ updated_test = manager.get_test(test.id)
267
+
268
+ expect(updated_test.status).to eq("completed")
269
+ end
270
+
271
+ it "cancels a test" do
272
+ manager.cancel_test(test.id)
273
+ updated_test = manager.get_test(test.id)
274
+
275
+ expect(updated_test.status).to eq("cancelled")
276
+ end
277
+ end
278
+
279
+ describe "statistical analysis" do
280
+ let(:test) do
281
+ test = manager.create_test(
282
+ name: "Statistical Test",
283
+ champion_version_id: @champion[:id],
284
+ challenger_version_id: @challenger[:id],
285
+ traffic_split: { champion: 50, challenger: 50 },
286
+ start_date: Time.now.utc + 3600
287
+ )
288
+ manager.start_test(test.id)
289
+ test
290
+ end
291
+
292
+ it "calculates improvement percentage" do
293
+ # Create assignments with different confidence levels
294
+ 50.times do |i|
295
+ assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
296
+
297
+ # Champion: avg 0.7, Challenger: avg 0.9
298
+ confidence = assignment[:variant] == :champion ? 0.7 : 0.9
299
+
300
+ manager.record_decision(
301
+ assignment_id: assignment[:assignment_id],
302
+ decision: "approve",
303
+ confidence: confidence
304
+ )
305
+ end
306
+
307
+ results = manager.get_results(test.id)
308
+
309
+ # Challenger should have higher avg confidence (0.9 vs 0.7)
310
+ expect(results[:comparison][:improvement_percentage]).to be > 0
311
+ expect(%w[champion challenger inconclusive]).to include(results[:comparison][:winner])
312
+ end
313
+
314
+ it "indicates insufficient data when sample is too small" do
315
+ # Create only a few assignments
316
+ 5.times do |i|
317
+ assignment = manager.assign_variant(test_id: test.id, user_id: "user_#{i}")
318
+ manager.record_decision(
319
+ assignment_id: assignment[:assignment_id],
320
+ decision: "approve",
321
+ confidence: 0.8
322
+ )
323
+ end
324
+
325
+ results = manager.get_results(test.id)
326
+
327
+ expect(results[:comparison][:statistical_significance]).to eq("not_significant")
328
+ end
329
+ end
330
+ end