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.
- checksums.yaml +4 -4
- data/README.md +212 -35
- data/bin/decision_agent +3 -8
- data/lib/decision_agent/ab_testing/ab_test.rb +197 -0
- data/lib/decision_agent/ab_testing/ab_test_assignment.rb +76 -0
- data/lib/decision_agent/ab_testing/ab_test_manager.rb +317 -0
- data/lib/decision_agent/ab_testing/ab_testing_agent.rb +152 -0
- data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +155 -0
- data/lib/decision_agent/ab_testing/storage/adapter.rb +67 -0
- data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +116 -0
- data/lib/decision_agent/agent.rb +19 -26
- data/lib/decision_agent/audit/null_adapter.rb +1 -2
- data/lib/decision_agent/decision.rb +3 -1
- data/lib/decision_agent/dsl/condition_evaluator.rb +4 -3
- data/lib/decision_agent/dsl/rule_parser.rb +4 -6
- data/lib/decision_agent/dsl/schema_validator.rb +27 -31
- data/lib/decision_agent/errors.rb +11 -8
- data/lib/decision_agent/evaluation.rb +3 -1
- data/lib/decision_agent/evaluation_validator.rb +78 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +26 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +2 -6
- data/lib/decision_agent/monitoring/alert_manager.rb +282 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +381 -0
- data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +471 -0
- data/lib/decision_agent/monitoring/dashboard/public/index.html +161 -0
- data/lib/decision_agent/monitoring/dashboard_server.rb +340 -0
- data/lib/decision_agent/monitoring/metrics_collector.rb +423 -0
- data/lib/decision_agent/monitoring/monitored_agent.rb +71 -0
- data/lib/decision_agent/monitoring/prometheus_exporter.rb +247 -0
- data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +253 -0
- data/lib/decision_agent/monitoring/storage/base_adapter.rb +90 -0
- data/lib/decision_agent/monitoring/storage/memory_adapter.rb +222 -0
- data/lib/decision_agent/replay/replay.rb +12 -22
- data/lib/decision_agent/scoring/base.rb +1 -1
- data/lib/decision_agent/scoring/consensus.rb +5 -5
- data/lib/decision_agent/scoring/weighted_average.rb +1 -1
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +69 -33
- data/lib/decision_agent/versioning/adapter.rb +1 -3
- data/lib/decision_agent/versioning/file_storage_adapter.rb +143 -35
- data/lib/decision_agent/versioning/version_manager.rb +4 -12
- data/lib/decision_agent/web/public/index.html +1 -1
- data/lib/decision_agent/web/server.rb +19 -24
- data/lib/decision_agent.rb +14 -0
- data/lib/generators/decision_agent/install/install_generator.rb +42 -5
- data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +45 -0
- data/lib/generators/decision_agent/install/templates/ab_test_model.rb +54 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +43 -0
- data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +189 -0
- data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +114 -0
- data/lib/generators/decision_agent/install/templates/decision_log.rb +57 -0
- data/lib/generators/decision_agent/install/templates/error_metric.rb +53 -0
- data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +43 -0
- data/lib/generators/decision_agent/install/templates/migration.rb +17 -6
- data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +109 -0
- data/lib/generators/decision_agent/install/templates/performance_metric.rb +76 -0
- data/lib/generators/decision_agent/install/templates/rule.rb +3 -3
- data/lib/generators/decision_agent/install/templates/rule_version.rb +13 -7
- data/spec/ab_testing/ab_test_manager_spec.rb +330 -0
- data/spec/ab_testing/ab_test_spec.rb +270 -0
- data/spec/activerecord_thread_safety_spec.rb +553 -0
- data/spec/agent_spec.rb +13 -13
- data/spec/api_contract_spec.rb +16 -16
- data/spec/audit_adapters_spec.rb +3 -3
- data/spec/comprehensive_edge_cases_spec.rb +86 -86
- data/spec/dsl_validation_spec.rb +83 -83
- data/spec/edge_cases_spec.rb +23 -23
- data/spec/examples/feedback_aware_evaluator_spec.rb +7 -7
- data/spec/examples.txt +612 -0
- data/spec/issue_verification_spec.rb +759 -0
- data/spec/json_rule_evaluator_spec.rb +15 -15
- data/spec/monitoring/alert_manager_spec.rb +378 -0
- data/spec/monitoring/metrics_collector_spec.rb +281 -0
- data/spec/monitoring/monitored_agent_spec.rb +222 -0
- data/spec/monitoring/prometheus_exporter_spec.rb +242 -0
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +346 -0
- data/spec/monitoring/storage/memory_adapter_spec.rb +247 -0
- data/spec/replay_edge_cases_spec.rb +58 -58
- data/spec/replay_spec.rb +11 -11
- data/spec/rfc8785_canonicalization_spec.rb +215 -0
- data/spec/scoring_spec.rb +1 -1
- data/spec/spec_helper.rb +9 -0
- data/spec/thread_safety_spec.rb +482 -0
- data/spec/thread_safety_spec.rb.broken +878 -0
- data/spec/versioning_spec.rb +141 -37
- data/spec/web_ui_rack_spec.rb +135 -0
- 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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
|
-
|
|
56
|
-
|
|
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
|