split 3.4.1 → 4.0.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/.github/FUNDING.yml +1 -0
- data/.github/dependabot.yml +7 -0
- data/.github/workflows/ci.yml +76 -0
- data/.rubocop.yml +177 -4
- data/CHANGELOG.md +87 -0
- data/CONTRIBUTING.md +1 -1
- data/Gemfile +2 -1
- data/README.md +37 -9
- data/Rakefile +5 -5
- data/gemfiles/5.2.gemfile +1 -3
- data/gemfiles/6.0.gemfile +1 -3
- data/gemfiles/{5.0.gemfile → 6.1.gemfile} +2 -4
- data/gemfiles/{5.1.gemfile → 7.0.gemfile} +2 -4
- data/lib/split/algorithms/block_randomization.rb +6 -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 +22 -22
- data/lib/split/cache.rb +27 -0
- data/lib/split/combined_experiments_helper.rb +5 -4
- data/lib/split/configuration.rb +89 -94
- data/lib/split/dashboard/helpers.rb +7 -7
- data/lib/split/dashboard/pagination_helpers.rb +54 -54
- data/lib/split/dashboard/paginator.rb +1 -0
- data/lib/split/dashboard/public/dashboard.js +10 -0
- data/lib/split/dashboard/public/style.css +10 -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 +19 -4
- data/lib/split/dashboard.rb +42 -21
- data/lib/split/encapsulated_helper.rb +15 -8
- data/lib/split/engine.rb +1 -0
- data/lib/split/exceptions.rb +1 -0
- data/lib/split/experiment.rb +151 -124
- data/lib/split/experiment_catalog.rb +7 -8
- data/lib/split/extensions/string.rb +2 -1
- data/lib/split/goals_collection.rb +9 -10
- data/lib/split/helper.rb +50 -23
- data/lib/split/metric.rb +6 -6
- data/lib/split/persistence/cookie_adapter.rb +46 -44
- data/lib/split/persistence/dual_adapter.rb +7 -8
- 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 +15 -29
- data/lib/split/trial.rb +43 -34
- data/lib/split/user.rb +25 -14
- data/lib/split/version.rb +2 -4
- data/lib/split/zscore.rb +2 -3
- data/lib/split.rb +34 -27
- 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 +69 -67
- data/spec/dashboard/paginator_spec.rb +10 -9
- data/spec/dashboard_helpers_spec.rb +19 -18
- data/spec/dashboard_spec.rb +122 -38
- data/spec/encapsulated_helper_spec.rb +46 -22
- data/spec/experiment_catalog_spec.rb +14 -13
- data/spec/experiment_spec.rb +198 -118
- data/spec/goals_collection_spec.rb +18 -16
- data/spec/helper_spec.rb +454 -385
- data/spec/metric_spec.rb +14 -14
- data/spec/persistence/cookie_adapter_spec.rb +26 -11
- data/spec/persistence/dual_adapter_spec.rb +71 -71
- 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 +35 -24
- data/spec/split_spec.rb +11 -11
- data/spec/support/cookies_mock.rb +1 -2
- data/spec/trial_spec.rb +102 -75
- data/spec/user_spec.rb +60 -29
- data/split.gemspec +22 -21
- metadata +43 -40
- data/.rubocop_todo.yml +0 -679
- data/.travis.yml +0 -60
- data/Appraisals +0 -19
- data/gemfiles/4.2.gemfile +0 -9
data/lib/split/experiment.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
class Experiment
|
|
4
5
|
attr_accessor :name
|
|
@@ -10,29 +11,22 @@ module Split
|
|
|
10
11
|
attr_reader :resettable
|
|
11
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
|
|
|
@@ -87,8 +85,8 @@ module Split
|
|
|
87
85
|
persist_experiment_configuration
|
|
88
86
|
end
|
|
89
87
|
|
|
90
|
-
redis.
|
|
91
|
-
|
|
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,11 +133,13 @@ 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
|
|
|
@@ -151,10 +151,11 @@ module Split
|
|
|
151
151
|
def winner=(winner_name)
|
|
152
152
|
redis.hset(:experiment_winner, name, winner_name.to_s)
|
|
153
153
|
@has_winner = true
|
|
154
|
+
Split.configuration.on_experiment_winner_choose.call(self)
|
|
154
155
|
end
|
|
155
156
|
|
|
156
157
|
def participant_count
|
|
157
|
-
alternatives.inject(0){|sum,a| sum + a.participant_count}
|
|
158
|
+
alternatives.inject(0) { |sum, a| sum + a.participant_count }
|
|
158
159
|
end
|
|
159
160
|
|
|
160
161
|
def control
|
|
@@ -164,6 +165,7 @@ module Split
|
|
|
164
165
|
def reset_winner
|
|
165
166
|
redis.hdel(:experiment_winner, name)
|
|
166
167
|
@has_winner = false
|
|
168
|
+
Split::Cache.clear_key(@name)
|
|
167
169
|
end
|
|
168
170
|
|
|
169
171
|
def start
|
|
@@ -171,13 +173,15 @@ module Split
|
|
|
171
173
|
end
|
|
172
174
|
|
|
173
175
|
def start_time
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
181
185
|
end
|
|
182
186
|
end
|
|
183
187
|
end
|
|
@@ -228,6 +232,7 @@ module Split
|
|
|
228
232
|
|
|
229
233
|
def reset
|
|
230
234
|
Split.configuration.on_before_experiment_reset.call(self)
|
|
235
|
+
Split::Cache.clear_key(@name)
|
|
231
236
|
alternatives.each(&:reset)
|
|
232
237
|
reset_winner
|
|
233
238
|
Split.configuration.on_experiment_reset.call(self)
|
|
@@ -241,6 +246,7 @@ module Split
|
|
|
241
246
|
end
|
|
242
247
|
reset_winner
|
|
243
248
|
redis.srem(:experiments, name)
|
|
249
|
+
remove_experiment_cohorting
|
|
244
250
|
remove_experiment_configuration
|
|
245
251
|
Split.configuration.on_experiment_delete.call(self)
|
|
246
252
|
increment_version
|
|
@@ -254,8 +260,8 @@ module Split
|
|
|
254
260
|
exp_config = redis.hgetall(experiment_config_key)
|
|
255
261
|
|
|
256
262
|
options = {
|
|
257
|
-
resettable: exp_config[
|
|
258
|
-
algorithm: exp_config[
|
|
263
|
+
resettable: exp_config["resettable"],
|
|
264
|
+
algorithm: exp_config["algorithm"],
|
|
259
265
|
alternatives: load_alternatives_from_redis,
|
|
260
266
|
goals: Split::GoalsCollection.new(@name).load_from_redis,
|
|
261
267
|
metadata: load_metadata_from_redis
|
|
@@ -264,7 +270,16 @@ module Split
|
|
|
264
270
|
set_alternatives_and_options(options)
|
|
265
271
|
end
|
|
266
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
|
+
|
|
267
280
|
def calc_winning_alternatives
|
|
281
|
+
return unless can_calculate_winning_alternatives?
|
|
282
|
+
|
|
268
283
|
# Cache the winning alternatives so we recalculate them once per the specified interval.
|
|
269
284
|
intervals_since_epoch =
|
|
270
285
|
Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
|
|
@@ -320,11 +335,11 @@ module Split
|
|
|
320
335
|
winning_counts.each do |alternative, wins|
|
|
321
336
|
alternative_probabilities[alternative] = wins / number_of_simulations.to_f
|
|
322
337
|
end
|
|
323
|
-
|
|
338
|
+
alternative_probabilities
|
|
324
339
|
end
|
|
325
340
|
|
|
326
341
|
def count_simulated_wins(winning_alternatives)
|
|
327
|
-
|
|
342
|
+
# initialize a hash to keep track of winning alternative in simulations
|
|
328
343
|
winning_counts = {}
|
|
329
344
|
alternatives.each do |alternative|
|
|
330
345
|
winning_counts[alternative] = 0
|
|
@@ -333,37 +348,33 @@ module Split
|
|
|
333
348
|
winning_alternatives.each do |alternative|
|
|
334
349
|
winning_counts[alternative] += 1
|
|
335
350
|
end
|
|
336
|
-
|
|
351
|
+
winning_counts
|
|
337
352
|
end
|
|
338
353
|
|
|
339
354
|
def find_simulated_winner(simulated_cr_hash)
|
|
340
355
|
# figure out which alternative had the highest simulated conversion rate
|
|
341
|
-
winning_pair = ["",0.0]
|
|
356
|
+
winning_pair = ["", 0.0]
|
|
342
357
|
simulated_cr_hash.each do |alternative, rate|
|
|
343
358
|
if rate > winning_pair[1]
|
|
344
359
|
winning_pair = [alternative, rate]
|
|
345
360
|
end
|
|
346
361
|
end
|
|
347
362
|
winner = winning_pair[0]
|
|
348
|
-
|
|
363
|
+
winner
|
|
349
364
|
end
|
|
350
365
|
|
|
351
366
|
def calc_simulated_conversion_rates(beta_params)
|
|
352
|
-
# initialize a random variable (from which to simulate conversion rates ~beta-distributed)
|
|
353
|
-
rand = SimpleRandom.new
|
|
354
|
-
rand.set_seed
|
|
355
|
-
|
|
356
367
|
simulated_cr_hash = {}
|
|
357
368
|
|
|
358
369
|
# create a hash which has the conversion rate pulled from each alternative's beta distribution
|
|
359
370
|
beta_params.each do |alternative, params|
|
|
360
371
|
alpha = params[0]
|
|
361
372
|
beta = params[1]
|
|
362
|
-
simulated_conversion_rate =
|
|
373
|
+
simulated_conversion_rate = Split::Algorithms.beta_distribution_rng(alpha, beta)
|
|
363
374
|
simulated_cr_hash[alternative] = simulated_conversion_rate
|
|
364
375
|
end
|
|
365
376
|
|
|
366
|
-
|
|
377
|
+
simulated_cr_hash
|
|
367
378
|
end
|
|
368
379
|
|
|
369
380
|
def calc_beta_params(goal = nil)
|
|
@@ -377,7 +388,7 @@ module Split
|
|
|
377
388
|
|
|
378
389
|
beta_params[alternative] = params
|
|
379
390
|
end
|
|
380
|
-
|
|
391
|
+
beta_params
|
|
381
392
|
end
|
|
382
393
|
|
|
383
394
|
def calc_time=(time)
|
|
@@ -390,93 +401,109 @@ module Split
|
|
|
390
401
|
|
|
391
402
|
def jstring(goal = nil)
|
|
392
403
|
js_id = if goal.nil?
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
js_id.gsub(
|
|
404
|
+
name
|
|
405
|
+
else
|
|
406
|
+
name + "-" + goal
|
|
407
|
+
end
|
|
408
|
+
js_id.gsub("/", "--")
|
|
398
409
|
end
|
|
399
410
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
404
416
|
end
|
|
405
417
|
|
|
406
|
-
def
|
|
407
|
-
|
|
418
|
+
def disable_cohorting
|
|
419
|
+
@cohorting_disabled = true
|
|
420
|
+
redis.hset(experiment_config_key, :cohorting, true.to_s)
|
|
408
421
|
end
|
|
409
422
|
|
|
410
|
-
def
|
|
411
|
-
|
|
412
|
-
|
|
423
|
+
def enable_cohorting
|
|
424
|
+
@cohorting_disabled = false
|
|
425
|
+
redis.hset(experiment_config_key, :cohorting, false.to_s)
|
|
413
426
|
end
|
|
414
427
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if alts.is_a?(Hash)
|
|
419
|
-
alts.keys
|
|
420
|
-
else
|
|
421
|
-
alts.flatten
|
|
428
|
+
protected
|
|
429
|
+
def experiment_config_key
|
|
430
|
+
"experiment_configurations/#{@name}"
|
|
422
431
|
end
|
|
423
|
-
end
|
|
424
432
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
442
462
|
end
|
|
443
|
-
end
|
|
444
463
|
|
|
445
464
|
private
|
|
465
|
+
def redis
|
|
466
|
+
Split.redis
|
|
467
|
+
end
|
|
446
468
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
469
|
+
def redis_interface
|
|
470
|
+
RedisInterface.new
|
|
471
|
+
end
|
|
450
472
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
454
477
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
478
|
+
if @metadata
|
|
479
|
+
redis.set(metadata_key, @metadata.to_json)
|
|
480
|
+
else
|
|
481
|
+
delete_metadata
|
|
482
|
+
end
|
|
483
|
+
end
|
|
461
484
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
485
|
+
def remove_experiment_configuration
|
|
486
|
+
@alternatives.each(&:delete)
|
|
487
|
+
goals_collection.delete
|
|
488
|
+
delete_metadata
|
|
489
|
+
redis.del(@name)
|
|
490
|
+
end
|
|
468
491
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
existing_goals = Split::GoalsCollection.new(@name).load_from_redis
|
|
472
|
-
existing_metadata = load_metadata_from_redis
|
|
473
|
-
existing_alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
|
|
474
|
-
existing_goals != @goals ||
|
|
475
|
-
existing_metadata != @metadata
|
|
476
|
-
end
|
|
492
|
+
def experiment_configuration_has_changed?
|
|
493
|
+
existing_experiment = Experiment.find(@name)
|
|
477
494
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
481
508
|
end
|
|
482
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,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Split
|
|
3
4
|
class GoalsCollection
|
|
4
|
-
|
|
5
|
-
def initialize(experiment_name, goals=nil)
|
|
5
|
+
def initialize(experiment_name, goals = nil)
|
|
6
6
|
@experiment_name = experiment_name
|
|
7
7
|
@goals = goals
|
|
8
8
|
end
|
|
@@ -14,10 +14,10 @@ module Split
|
|
|
14
14
|
def load_from_configuration
|
|
15
15
|
goals = Split.configuration.experiment_for(@experiment_name)[:goals]
|
|
16
16
|
|
|
17
|
-
if goals
|
|
18
|
-
goals = []
|
|
19
|
-
else
|
|
17
|
+
if goals
|
|
20
18
|
goals.flatten
|
|
19
|
+
else
|
|
20
|
+
[]
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -28,7 +28,7 @@ module Split
|
|
|
28
28
|
|
|
29
29
|
def validate!
|
|
30
30
|
unless @goals.nil? || @goals.kind_of?(Array)
|
|
31
|
-
raise ArgumentError,
|
|
31
|
+
raise ArgumentError, "Goals must be an array"
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -37,9 +37,8 @@ module Split
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
private
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
end
|
|
40
|
+
def goals_key
|
|
41
|
+
"#{@experiment_name}:goals"
|
|
42
|
+
end
|
|
44
43
|
end
|
|
45
44
|
end
|