split 3.2.0 → 4.0.5
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 +5 -5
- data/.eslintrc +1 -1
- data/.github/FUNDING.yml +1 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
- data/.github/dependabot.yml +7 -0
- data/.github/workflows/ci.yml +63 -0
- data/.rspec +1 -0
- data/.rubocop.yml +67 -1043
- data/CHANGELOG.md +174 -0
- data/CODE_OF_CONDUCT.md +3 -3
- data/CONTRIBUTING.md +1 -1
- data/Gemfile +6 -1
- data/README.md +79 -33
- data/Rakefile +6 -5
- data/lib/split/algorithms/block_randomization.rb +7 -6
- data/lib/split/algorithms/weighted_sample.rb +2 -1
- data/lib/split/algorithms/whiplash.rb +17 -18
- data/lib/split/algorithms.rb +14 -0
- data/lib/split/alternative.rb +25 -25
- data/lib/split/cache.rb +27 -0
- data/lib/split/combined_experiments_helper.rb +6 -5
- data/lib/split/configuration.rb +94 -91
- data/lib/split/dashboard/helpers.rb +9 -9
- data/lib/split/dashboard/pagination_helpers.rb +86 -0
- data/lib/split/dashboard/paginator.rb +17 -0
- data/lib/split/dashboard/public/dashboard.js +10 -0
- data/lib/split/dashboard/public/style.css +19 -2
- data/lib/split/dashboard/views/_controls.erb +13 -0
- data/lib/split/dashboard/views/_experiment.erb +2 -1
- data/lib/split/dashboard/views/index.erb +24 -5
- data/lib/split/dashboard/views/layout.erb +1 -1
- data/lib/split/dashboard.rb +47 -20
- data/lib/split/encapsulated_helper.rb +15 -8
- data/lib/split/engine.rb +7 -4
- data/lib/split/exceptions.rb +1 -0
- data/lib/split/experiment.rb +160 -122
- data/lib/split/experiment_catalog.rb +7 -8
- data/lib/split/extensions/string.rb +2 -1
- data/lib/split/goals_collection.rb +10 -10
- data/lib/split/helper.rb +56 -24
- data/lib/split/metric.rb +6 -6
- data/lib/split/persistence/cookie_adapter.rb +52 -15
- data/lib/split/persistence/dual_adapter.rb +53 -12
- data/lib/split/persistence/redis_adapter.rb +8 -4
- data/lib/split/persistence/session_adapter.rb +1 -2
- data/lib/split/persistence.rb +8 -6
- data/lib/split/redis_interface.rb +16 -31
- data/lib/split/trial.rb +48 -41
- data/lib/split/user.rb +30 -15
- data/lib/split/version.rb +2 -4
- data/lib/split/zscore.rb +2 -3
- data/lib/split.rb +39 -25
- data/spec/algorithms/block_randomization_spec.rb +6 -5
- data/spec/algorithms/weighted_sample_spec.rb +6 -5
- data/spec/algorithms/whiplash_spec.rb +4 -5
- data/spec/alternative_spec.rb +35 -36
- data/spec/cache_spec.rb +84 -0
- data/spec/combined_experiments_helper_spec.rb +18 -17
- data/spec/configuration_spec.rb +41 -45
- data/spec/dashboard/pagination_helpers_spec.rb +202 -0
- data/spec/dashboard/paginator_spec.rb +38 -0
- data/spec/dashboard_helpers_spec.rb +19 -18
- data/spec/dashboard_spec.rb +153 -48
- data/spec/encapsulated_helper_spec.rb +47 -23
- data/spec/experiment_catalog_spec.rb +14 -13
- data/spec/experiment_spec.rb +224 -111
- data/spec/goals_collection_spec.rb +18 -16
- data/spec/helper_spec.rb +539 -419
- data/spec/metric_spec.rb +14 -14
- data/spec/persistence/cookie_adapter_spec.rb +105 -27
- data/spec/persistence/dual_adapter_spec.rb +158 -66
- data/spec/persistence/redis_adapter_spec.rb +35 -27
- data/spec/persistence/session_adapter_spec.rb +2 -3
- data/spec/persistence_spec.rb +1 -2
- data/spec/redis_interface_spec.rb +25 -82
- data/spec/spec_helper.rb +38 -24
- data/spec/split_spec.rb +18 -18
- data/spec/support/cookies_mock.rb +1 -2
- data/spec/trial_spec.rb +117 -70
- data/spec/user_spec.rb +69 -27
- data/split.gemspec +26 -22
- metadata +85 -37
- data/.travis.yml +0 -41
- data/Appraisals +0 -13
- data/gemfiles/4.2.gemfile +0 -9
- data/gemfiles/5.0.gemfile +0 -10
- data/gemfiles/5.1.gemfile +0 -10
data/lib/split/experiment.rb
CHANGED
|
@@ -1,38 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
class Experiment
|
|
4
5
|
attr_accessor :name
|
|
5
|
-
attr_writer :algorithm
|
|
6
|
-
attr_accessor :resettable
|
|
7
6
|
attr_accessor :goals
|
|
8
|
-
attr_accessor :alternatives
|
|
9
7
|
attr_accessor :alternative_probabilities
|
|
10
8
|
attr_accessor :metadata
|
|
11
9
|
|
|
10
|
+
attr_reader :alternatives
|
|
11
|
+
attr_reader :resettable
|
|
12
|
+
|
|
12
13
|
DEFAULT_OPTIONS = {
|
|
13
|
-
:
|
|
14
|
+
resettable: true
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
def self.find(name)
|
|
18
|
+
Split.cache(:experiments, name) do
|
|
19
|
+
return unless Split.redis.exists?(name)
|
|
20
|
+
Experiment.new(name).tap { |exp| exp.load_from_redis }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
def initialize(name, options = {})
|
|
17
25
|
options = DEFAULT_OPTIONS.merge(options)
|
|
18
26
|
|
|
19
27
|
@name = name.to_s
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if alternatives.empty? && (exp_config = Split.configuration.experiment_for(name))
|
|
24
|
-
options = {
|
|
25
|
-
alternatives: load_alternatives_from_configuration,
|
|
26
|
-
goals: Split::GoalsCollection.new(@name).load_from_configuration,
|
|
27
|
-
metadata: load_metadata_from_configuration,
|
|
28
|
-
resettable: exp_config[:resettable],
|
|
29
|
-
algorithm: exp_config[:algorithm]
|
|
30
|
-
}
|
|
31
|
-
else
|
|
32
|
-
options[:alternatives] = alternatives
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
set_alternatives_and_options(options)
|
|
29
|
+
extract_alternatives_from_options(options)
|
|
36
30
|
end
|
|
37
31
|
|
|
38
32
|
def self.finished_key(key)
|
|
@@ -40,11 +34,15 @@ module Split
|
|
|
40
34
|
end
|
|
41
35
|
|
|
42
36
|
def set_alternatives_and_options(options)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
self.
|
|
37
|
+
options_with_defaults = DEFAULT_OPTIONS.merge(
|
|
38
|
+
options.reject { |k, v| v.nil? }
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self.alternatives = options_with_defaults[:alternatives]
|
|
42
|
+
self.goals = options_with_defaults[:goals]
|
|
43
|
+
self.resettable = options_with_defaults[:resettable]
|
|
44
|
+
self.algorithm = options_with_defaults[:algorithm]
|
|
45
|
+
self.metadata = options_with_defaults[:metadata]
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
def extract_alternatives_from_options(options)
|
|
@@ -52,7 +50,7 @@ module Split
|
|
|
52
50
|
|
|
53
51
|
if alts.length == 1
|
|
54
52
|
if alts[0].is_a? Hash
|
|
55
|
-
alts = alts[0].map{|k,v| {k => v} }
|
|
53
|
+
alts = alts[0].map { |k, v| { k => v } }
|
|
56
54
|
end
|
|
57
55
|
end
|
|
58
56
|
|
|
@@ -81,14 +79,14 @@ module Split
|
|
|
81
79
|
|
|
82
80
|
if new_record?
|
|
83
81
|
start unless Split.configuration.start_manually
|
|
82
|
+
persist_experiment_configuration
|
|
84
83
|
elsif experiment_configuration_has_changed?
|
|
85
84
|
reset unless Split.configuration.reset_manually
|
|
85
|
+
persist_experiment_configuration
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
redis.hset(experiment_config_key, :resettable, resettable)
|
|
91
|
-
redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
|
|
88
|
+
redis.hmset(experiment_config_key, :resettable, resettable.to_s,
|
|
89
|
+
:algorithm, algorithm.to_s)
|
|
92
90
|
self
|
|
93
91
|
end
|
|
94
92
|
|
|
@@ -96,12 +94,12 @@ module Split
|
|
|
96
94
|
if @alternatives.empty? && Split.configuration.experiment_for(@name).nil?
|
|
97
95
|
raise ExperimentNotFound.new("Experiment #{@name} not found")
|
|
98
96
|
end
|
|
99
|
-
@alternatives.each {|a| a.validate! }
|
|
97
|
+
@alternatives.each { |a| a.validate! }
|
|
100
98
|
goals_collection.validate!
|
|
101
99
|
end
|
|
102
100
|
|
|
103
101
|
def new_record?
|
|
104
|
-
|
|
102
|
+
ExperimentCatalog.find(name).nil?
|
|
105
103
|
end
|
|
106
104
|
|
|
107
105
|
def ==(obj)
|
|
@@ -109,7 +107,7 @@ module Split
|
|
|
109
107
|
end
|
|
110
108
|
|
|
111
109
|
def [](name)
|
|
112
|
-
alternatives.find{|a| a.name == name}
|
|
110
|
+
alternatives.find { |a| a.name == name }
|
|
113
111
|
end
|
|
114
112
|
|
|
115
113
|
def algorithm
|
|
@@ -121,7 +119,7 @@ module Split
|
|
|
121
119
|
end
|
|
122
120
|
|
|
123
121
|
def resettable=(resettable)
|
|
124
|
-
@resettable = resettable.is_a?(String) ? resettable ==
|
|
122
|
+
@resettable = resettable.is_a?(String) ? resettable == "true" : resettable
|
|
125
123
|
end
|
|
126
124
|
|
|
127
125
|
def alternatives=(alts)
|
|
@@ -135,24 +133,29 @@ module Split
|
|
|
135
133
|
end
|
|
136
134
|
|
|
137
135
|
def winner
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
Split.cache(:experiment_winner, name) do
|
|
137
|
+
experiment_winner = redis.hget(:experiment_winner, name)
|
|
138
|
+
if experiment_winner
|
|
139
|
+
Split::Alternative.new(experiment_winner, name)
|
|
140
|
+
else
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
143
|
end
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
def has_winner?
|
|
147
|
-
|
|
147
|
+
return @has_winner if defined? @has_winner
|
|
148
|
+
@has_winner = !winner.nil?
|
|
148
149
|
end
|
|
149
150
|
|
|
150
151
|
def winner=(winner_name)
|
|
151
152
|
redis.hset(:experiment_winner, name, winner_name.to_s)
|
|
153
|
+
@has_winner = true
|
|
154
|
+
Split.configuration.on_experiment_winner_choose.call(self)
|
|
152
155
|
end
|
|
153
156
|
|
|
154
157
|
def participant_count
|
|
155
|
-
alternatives.inject(0){|sum,a| sum + a.participant_count}
|
|
158
|
+
alternatives.inject(0) { |sum, a| sum + a.participant_count }
|
|
156
159
|
end
|
|
157
160
|
|
|
158
161
|
def control
|
|
@@ -161,6 +164,8 @@ module Split
|
|
|
161
164
|
|
|
162
165
|
def reset_winner
|
|
163
166
|
redis.hdel(:experiment_winner, name)
|
|
167
|
+
@has_winner = false
|
|
168
|
+
Split::Cache.clear_key(@name)
|
|
164
169
|
end
|
|
165
170
|
|
|
166
171
|
def start
|
|
@@ -168,13 +173,15 @@ module Split
|
|
|
168
173
|
end
|
|
169
174
|
|
|
170
175
|
def start_time
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
Split.cache(:experiment_start_times, @name) do
|
|
177
|
+
t = redis.hget(:experiment_start_times, @name)
|
|
178
|
+
if t
|
|
179
|
+
# Check if stored time is an integer
|
|
180
|
+
if t =~ /^[-+]?[0-9]+$/
|
|
181
|
+
Time.at(t.to_i)
|
|
182
|
+
else
|
|
183
|
+
Time.parse(t)
|
|
184
|
+
end
|
|
178
185
|
end
|
|
179
186
|
end
|
|
180
187
|
end
|
|
@@ -225,6 +232,7 @@ module Split
|
|
|
225
232
|
|
|
226
233
|
def reset
|
|
227
234
|
Split.configuration.on_before_experiment_reset.call(self)
|
|
235
|
+
Split::Cache.clear_key(@name)
|
|
228
236
|
alternatives.each(&:reset)
|
|
229
237
|
reset_winner
|
|
230
238
|
Split.configuration.on_experiment_reset.call(self)
|
|
@@ -238,6 +246,7 @@ module Split
|
|
|
238
246
|
end
|
|
239
247
|
reset_winner
|
|
240
248
|
redis.srem(:experiments, name)
|
|
249
|
+
remove_experiment_cohorting
|
|
241
250
|
remove_experiment_configuration
|
|
242
251
|
Split.configuration.on_experiment_delete.call(self)
|
|
243
252
|
increment_version
|
|
@@ -251,8 +260,8 @@ module Split
|
|
|
251
260
|
exp_config = redis.hgetall(experiment_config_key)
|
|
252
261
|
|
|
253
262
|
options = {
|
|
254
|
-
resettable: exp_config[
|
|
255
|
-
algorithm: exp_config[
|
|
263
|
+
resettable: exp_config["resettable"],
|
|
264
|
+
algorithm: exp_config["algorithm"],
|
|
256
265
|
alternatives: load_alternatives_from_redis,
|
|
257
266
|
goals: Split::GoalsCollection.new(@name).load_from_redis,
|
|
258
267
|
metadata: load_metadata_from_redis
|
|
@@ -261,7 +270,16 @@ module Split
|
|
|
261
270
|
set_alternatives_and_options(options)
|
|
262
271
|
end
|
|
263
272
|
|
|
273
|
+
def can_calculate_winning_alternatives?
|
|
274
|
+
self.alternatives.all? do |alternative|
|
|
275
|
+
alternative.participant_count >= 0 &&
|
|
276
|
+
(alternative.participant_count >= alternative.completed_count)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
264
280
|
def calc_winning_alternatives
|
|
281
|
+
return unless can_calculate_winning_alternatives?
|
|
282
|
+
|
|
265
283
|
# Cache the winning alternatives so we recalculate them once per the specified interval.
|
|
266
284
|
intervals_since_epoch =
|
|
267
285
|
Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
|
|
@@ -317,11 +335,11 @@ module Split
|
|
|
317
335
|
winning_counts.each do |alternative, wins|
|
|
318
336
|
alternative_probabilities[alternative] = wins / number_of_simulations.to_f
|
|
319
337
|
end
|
|
320
|
-
|
|
338
|
+
alternative_probabilities
|
|
321
339
|
end
|
|
322
340
|
|
|
323
341
|
def count_simulated_wins(winning_alternatives)
|
|
324
|
-
|
|
342
|
+
# initialize a hash to keep track of winning alternative in simulations
|
|
325
343
|
winning_counts = {}
|
|
326
344
|
alternatives.each do |alternative|
|
|
327
345
|
winning_counts[alternative] = 0
|
|
@@ -330,37 +348,33 @@ module Split
|
|
|
330
348
|
winning_alternatives.each do |alternative|
|
|
331
349
|
winning_counts[alternative] += 1
|
|
332
350
|
end
|
|
333
|
-
|
|
351
|
+
winning_counts
|
|
334
352
|
end
|
|
335
353
|
|
|
336
354
|
def find_simulated_winner(simulated_cr_hash)
|
|
337
355
|
# figure out which alternative had the highest simulated conversion rate
|
|
338
|
-
winning_pair = ["",0.0]
|
|
356
|
+
winning_pair = ["", 0.0]
|
|
339
357
|
simulated_cr_hash.each do |alternative, rate|
|
|
340
358
|
if rate > winning_pair[1]
|
|
341
359
|
winning_pair = [alternative, rate]
|
|
342
360
|
end
|
|
343
361
|
end
|
|
344
362
|
winner = winning_pair[0]
|
|
345
|
-
|
|
363
|
+
winner
|
|
346
364
|
end
|
|
347
365
|
|
|
348
366
|
def calc_simulated_conversion_rates(beta_params)
|
|
349
|
-
# initialize a random variable (from which to simulate conversion rates ~beta-distributed)
|
|
350
|
-
rand = SimpleRandom.new
|
|
351
|
-
rand.set_seed
|
|
352
|
-
|
|
353
367
|
simulated_cr_hash = {}
|
|
354
368
|
|
|
355
369
|
# create a hash which has the conversion rate pulled from each alternative's beta distribution
|
|
356
370
|
beta_params.each do |alternative, params|
|
|
357
371
|
alpha = params[0]
|
|
358
372
|
beta = params[1]
|
|
359
|
-
simulated_conversion_rate =
|
|
373
|
+
simulated_conversion_rate = Split::Algorithms.beta_distribution_rng(alpha, beta)
|
|
360
374
|
simulated_cr_hash[alternative] = simulated_conversion_rate
|
|
361
375
|
end
|
|
362
376
|
|
|
363
|
-
|
|
377
|
+
simulated_cr_hash
|
|
364
378
|
end
|
|
365
379
|
|
|
366
380
|
def calc_beta_params(goal = nil)
|
|
@@ -374,7 +388,7 @@ module Split
|
|
|
374
388
|
|
|
375
389
|
beta_params[alternative] = params
|
|
376
390
|
end
|
|
377
|
-
|
|
391
|
+
beta_params
|
|
378
392
|
end
|
|
379
393
|
|
|
380
394
|
def calc_time=(time)
|
|
@@ -387,85 +401,109 @@ module Split
|
|
|
387
401
|
|
|
388
402
|
def jstring(goal = nil)
|
|
389
403
|
js_id = if goal.nil?
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
js_id.gsub(
|
|
404
|
+
name
|
|
405
|
+
else
|
|
406
|
+
name + "-" + goal
|
|
407
|
+
end
|
|
408
|
+
js_id.gsub("/", "--")
|
|
395
409
|
end
|
|
396
410
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
411
|
+
def cohorting_disabled?
|
|
412
|
+
@cohorting_disabled ||= begin
|
|
413
|
+
value = redis.hget(experiment_config_key, :cohorting)
|
|
414
|
+
value.nil? ? false : value.downcase == "true"
|
|
415
|
+
end
|
|
401
416
|
end
|
|
402
417
|
|
|
403
|
-
def
|
|
404
|
-
|
|
418
|
+
def disable_cohorting
|
|
419
|
+
@cohorting_disabled = true
|
|
420
|
+
redis.hset(experiment_config_key, :cohorting, true.to_s)
|
|
405
421
|
end
|
|
406
422
|
|
|
407
|
-
def
|
|
408
|
-
|
|
409
|
-
|
|
423
|
+
def enable_cohorting
|
|
424
|
+
@cohorting_disabled = false
|
|
425
|
+
redis.hset(experiment_config_key, :cohorting, false.to_s)
|
|
410
426
|
end
|
|
411
427
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if alts.is_a?(Hash)
|
|
416
|
-
alts.keys
|
|
417
|
-
else
|
|
418
|
-
alts.flatten
|
|
428
|
+
protected
|
|
429
|
+
def experiment_config_key
|
|
430
|
+
"experiment_configurations/#{@name}"
|
|
419
431
|
end
|
|
420
|
-
end
|
|
421
432
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
433
|
+
def load_metadata_from_configuration
|
|
434
|
+
Split.configuration.experiment_for(@name)[:metadata]
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def load_metadata_from_redis
|
|
438
|
+
meta = redis.get(metadata_key)
|
|
439
|
+
JSON.parse(meta) unless meta.nil?
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def load_alternatives_from_configuration
|
|
443
|
+
alts = Split.configuration.experiment_for(@name)[:alternatives]
|
|
444
|
+
raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
|
|
445
|
+
if alts.is_a?(Hash)
|
|
446
|
+
alts.keys
|
|
447
|
+
else
|
|
448
|
+
alts.flatten
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def load_alternatives_from_redis
|
|
453
|
+
alternatives = redis.lrange(@name, 0, -1)
|
|
454
|
+
alternatives.map do |alt|
|
|
455
|
+
alt = begin
|
|
456
|
+
JSON.parse(alt)
|
|
457
|
+
rescue
|
|
458
|
+
alt
|
|
459
|
+
end
|
|
460
|
+
Split::Alternative.new(alt, @name)
|
|
461
|
+
end
|
|
431
462
|
end
|
|
432
|
-
end
|
|
433
463
|
|
|
434
464
|
private
|
|
465
|
+
def redis
|
|
466
|
+
Split.redis
|
|
467
|
+
end
|
|
435
468
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
469
|
+
def redis_interface
|
|
470
|
+
RedisInterface.new
|
|
471
|
+
end
|
|
439
472
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
473
|
+
def persist_experiment_configuration
|
|
474
|
+
redis_interface.add_to_set(:experiments, name)
|
|
475
|
+
redis_interface.persist_list(name, @alternatives.map { |alt| { alt.name => alt.weight }.to_json })
|
|
476
|
+
goals_collection.save
|
|
443
477
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
478
|
+
if @metadata
|
|
479
|
+
redis.set(metadata_key, @metadata.to_json)
|
|
480
|
+
else
|
|
481
|
+
delete_metadata
|
|
482
|
+
end
|
|
483
|
+
end
|
|
450
484
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
485
|
+
def remove_experiment_configuration
|
|
486
|
+
@alternatives.each(&:delete)
|
|
487
|
+
goals_collection.delete
|
|
488
|
+
delete_metadata
|
|
489
|
+
redis.del(@name)
|
|
490
|
+
end
|
|
457
491
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
existing_goals = Split::GoalsCollection.new(@name).load_from_redis
|
|
461
|
-
existing_metadata = load_metadata_from_redis
|
|
462
|
-
existing_alternatives != @alternatives.map(&:name) ||
|
|
463
|
-
existing_goals != @goals ||
|
|
464
|
-
existing_metadata != @metadata
|
|
465
|
-
end
|
|
492
|
+
def experiment_configuration_has_changed?
|
|
493
|
+
existing_experiment = Experiment.find(@name)
|
|
466
494
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
495
|
+
existing_experiment.alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
|
|
496
|
+
existing_experiment.goals != @goals ||
|
|
497
|
+
existing_experiment.metadata != @metadata
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def goals_collection
|
|
501
|
+
Split::GoalsCollection.new(@name, @goals)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def remove_experiment_cohorting
|
|
505
|
+
@cohorting_disabled = false
|
|
506
|
+
redis.hdel(experiment_config_key, :cohorting)
|
|
507
|
+
end
|
|
470
508
|
end
|
|
471
509
|
end
|
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
class ExperimentCatalog
|
|
4
5
|
# Return all experiments
|
|
5
6
|
def self.all
|
|
6
7
|
# Call compact to prevent nil experiments from being returned -- seems to happen during gem upgrades
|
|
7
|
-
Split.redis.smembers(:experiments).map {|e| find(e)}.compact
|
|
8
|
+
Split.redis.smembers(:experiments).map { |e| find(e) }.compact
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
# Return experiments without a winner (considered "active") first
|
|
11
12
|
def self.all_active_first
|
|
12
|
-
all.partition{|e| not e.winner}.map{|es| es.sort_by(&:name)}.flatten
|
|
13
|
+
all.partition { |e| not e.winner }.map { |es| es.sort_by(&:name) }.flatten
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def self.find(name)
|
|
16
|
-
|
|
17
|
-
Experiment.new(name).tap { |exp| exp.load_from_redis }
|
|
17
|
+
Experiment.find(name)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def self.find_or_initialize(metric_descriptor, control = nil, *alternatives)
|
|
21
21
|
# Check if array is passed to ab_test
|
|
22
22
|
# e.g. ab_test('name', ['Alt 1', 'Alt 2', 'Alt 3'])
|
|
23
|
-
if control.is_a?
|
|
23
|
+
if control.is_a?(Array) && alternatives.length.zero?
|
|
24
24
|
control, alternatives = control.first, control[1..-1]
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
experiment_name_with_version, goals = normalize_experiment(metric_descriptor)
|
|
28
|
-
experiment_name = experiment_name_with_version.to_s.split(
|
|
28
|
+
experiment_name = experiment_name_with_version.to_s.split(":")[0]
|
|
29
29
|
Split::Experiment.new(experiment_name,
|
|
30
|
-
:
|
|
30
|
+
alternatives: [control].compact + alternatives, goals: goals)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def self.find_or_create(metric_descriptor, control = nil, *alternatives)
|
|
@@ -46,6 +46,5 @@ module Split
|
|
|
46
46
|
return experiment_name, goals
|
|
47
47
|
end
|
|
48
48
|
private_class_method :normalize_experiment
|
|
49
|
-
|
|
50
49
|
end
|
|
51
50
|
end
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
class String
|
|
3
4
|
# Constatntize is often provided by ActiveSupport, but ActiveSupport is not a dependency of Split.
|
|
4
5
|
unless method_defined?(:constantize)
|
|
5
6
|
def constantize
|
|
6
|
-
names = self.split(
|
|
7
|
+
names = self.split("::")
|
|
7
8
|
names.shift if names.empty? || names.first.empty?
|
|
8
9
|
|
|
9
10
|
constant = Object
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Split
|
|
2
4
|
class GoalsCollection
|
|
3
|
-
|
|
4
|
-
def initialize(experiment_name, goals=nil)
|
|
5
|
+
def initialize(experiment_name, goals = nil)
|
|
5
6
|
@experiment_name = experiment_name
|
|
6
7
|
@goals = goals
|
|
7
8
|
end
|
|
@@ -13,10 +14,10 @@ module Split
|
|
|
13
14
|
def load_from_configuration
|
|
14
15
|
goals = Split.configuration.experiment_for(@experiment_name)[:goals]
|
|
15
16
|
|
|
16
|
-
if goals
|
|
17
|
-
goals = []
|
|
18
|
-
else
|
|
17
|
+
if goals
|
|
19
18
|
goals.flatten
|
|
19
|
+
else
|
|
20
|
+
[]
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
|
|
@@ -27,7 +28,7 @@ module Split
|
|
|
27
28
|
|
|
28
29
|
def validate!
|
|
29
30
|
unless @goals.nil? || @goals.kind_of?(Array)
|
|
30
|
-
raise ArgumentError,
|
|
31
|
+
raise ArgumentError, "Goals must be an array"
|
|
31
32
|
end
|
|
32
33
|
end
|
|
33
34
|
|
|
@@ -36,9 +37,8 @@ module Split
|
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
private
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
40
|
+
def goals_key
|
|
41
|
+
"#{@experiment_name}:goals"
|
|
42
|
+
end
|
|
43
43
|
end
|
|
44
44
|
end
|