split 3.4.1 → 4.0.0.pre
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/.rubocop.yml +177 -1
- data/.rubocop_todo.yml +40 -493
- data/.travis.yml +14 -42
- data/CHANGELOG.md +35 -0
- data/Gemfile +1 -0
- data/README.md +19 -1
- data/Rakefile +1 -0
- data/lib/split.rb +8 -2
- data/lib/split/algorithms/block_randomization.rb +1 -0
- data/lib/split/algorithms/weighted_sample.rb +2 -1
- data/lib/split/algorithms/whiplash.rb +3 -2
- data/lib/split/alternative.rb +1 -0
- data/lib/split/cache.rb +28 -0
- data/lib/split/combined_experiments_helper.rb +1 -0
- data/lib/split/configuration.rb +6 -12
- data/lib/split/dashboard.rb +17 -2
- data/lib/split/dashboard/helpers.rb +1 -0
- data/lib/split/dashboard/pagination_helpers.rb +1 -0
- data/lib/split/dashboard/paginator.rb +1 -0
- data/lib/split/dashboard/public/dashboard.js +10 -0
- data/lib/split/dashboard/public/style.css +5 -0
- data/lib/split/dashboard/views/_controls.erb +13 -0
- data/lib/split/encapsulated_helper.rb +3 -2
- data/lib/split/engine.rb +1 -0
- data/lib/split/exceptions.rb +1 -0
- data/lib/split/experiment.rb +81 -59
- data/lib/split/experiment_catalog.rb +1 -3
- data/lib/split/extensions/string.rb +1 -0
- data/lib/split/goals_collection.rb +1 -0
- data/lib/split/helper.rb +26 -7
- data/lib/split/metric.rb +2 -1
- data/lib/split/persistence.rb +4 -2
- data/lib/split/persistence/cookie_adapter.rb +1 -0
- data/lib/split/persistence/redis_adapter.rb +5 -0
- data/lib/split/persistence/session_adapter.rb +1 -0
- data/lib/split/redis_interface.rb +8 -28
- data/lib/split/trial.rb +20 -10
- data/lib/split/user.rb +14 -2
- data/lib/split/version.rb +2 -4
- data/lib/split/zscore.rb +1 -0
- data/spec/alternative_spec.rb +1 -1
- data/spec/cache_spec.rb +88 -0
- data/spec/configuration_spec.rb +1 -14
- data/spec/dashboard_spec.rb +45 -5
- data/spec/encapsulated_helper_spec.rb +1 -1
- data/spec/experiment_spec.rb +78 -7
- data/spec/goals_collection_spec.rb +1 -1
- data/spec/helper_spec.rb +68 -32
- data/spec/persistence/cookie_adapter_spec.rb +1 -1
- data/spec/persistence/redis_adapter_spec.rb +9 -0
- data/spec/redis_interface_spec.rb +0 -69
- data/spec/spec_helper.rb +5 -6
- data/spec/trial_spec.rb +45 -19
- data/spec/user_spec.rb +17 -0
- data/split.gemspec +7 -7
- metadata +23 -34
- data/gemfiles/4.2.gemfile +0 -9
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
require "split/helper"
|
3
4
|
|
4
5
|
# Split's helper exposes all kinds of methods we don't want to
|
@@ -28,8 +29,8 @@ module Split
|
|
28
29
|
end
|
29
30
|
end
|
30
31
|
|
31
|
-
def ab_test(*arguments
|
32
|
-
split_context_shim.ab_test(*arguments
|
32
|
+
def ab_test(*arguments, &block)
|
33
|
+
split_context_shim.ab_test(*arguments, &block)
|
33
34
|
end
|
34
35
|
|
35
36
|
private
|
data/lib/split/engine.rb
CHANGED
data/lib/split/exceptions.rb
CHANGED
data/lib/split/experiment.rb
CHANGED
@@ -1,4 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rubystats'
|
4
|
+
|
2
5
|
module Split
|
3
6
|
class Experiment
|
4
7
|
attr_accessor :name
|
@@ -13,26 +16,19 @@ module Split
|
|
13
16
|
:resettable => true
|
14
17
|
}
|
15
18
|
|
19
|
+
def self.find(name)
|
20
|
+
Split.cache(:experiments, name) do
|
21
|
+
return unless Split.redis.exists?(name)
|
22
|
+
Experiment.new(name).tap { |exp| exp.load_from_redis }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
16
26
|
def initialize(name, options = {})
|
17
27
|
options = DEFAULT_OPTIONS.merge(options)
|
18
28
|
|
19
29
|
@name = name.to_s
|
20
30
|
|
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)
|
31
|
+
extract_alternatives_from_options(options)
|
36
32
|
end
|
37
33
|
|
38
34
|
def self.finished_key(key)
|
@@ -40,11 +36,15 @@ module Split
|
|
40
36
|
end
|
41
37
|
|
42
38
|
def set_alternatives_and_options(options)
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
self.
|
39
|
+
options_with_defaults = DEFAULT_OPTIONS.merge(
|
40
|
+
options.reject { |k, v| v.nil? }
|
41
|
+
)
|
42
|
+
|
43
|
+
self.alternatives = options_with_defaults[:alternatives]
|
44
|
+
self.goals = options_with_defaults[:goals]
|
45
|
+
self.resettable = options_with_defaults[:resettable]
|
46
|
+
self.algorithm = options_with_defaults[:algorithm]
|
47
|
+
self.metadata = options_with_defaults[:metadata]
|
48
48
|
end
|
49
49
|
|
50
50
|
def extract_alternatives_from_options(options)
|
@@ -52,7 +52,7 @@ module Split
|
|
52
52
|
|
53
53
|
if alts.length == 1
|
54
54
|
if alts[0].is_a? Hash
|
55
|
-
alts = alts[0].map{|k,v| {k => v} }
|
55
|
+
alts = alts[0].map{|k, v| {k => v} }
|
56
56
|
end
|
57
57
|
end
|
58
58
|
|
@@ -87,8 +87,8 @@ module Split
|
|
87
87
|
persist_experiment_configuration
|
88
88
|
end
|
89
89
|
|
90
|
-
redis.hset(experiment_config_key, :resettable, resettable
|
91
|
-
|
90
|
+
redis.hset(experiment_config_key, :resettable, resettable,
|
91
|
+
:algorithm, algorithm.to_s)
|
92
92
|
self
|
93
93
|
end
|
94
94
|
|
@@ -101,7 +101,7 @@ module Split
|
|
101
101
|
end
|
102
102
|
|
103
103
|
def new_record?
|
104
|
-
|
104
|
+
ExperimentCatalog.find(name).nil?
|
105
105
|
end
|
106
106
|
|
107
107
|
def ==(obj)
|
@@ -135,11 +135,13 @@ module Split
|
|
135
135
|
end
|
136
136
|
|
137
137
|
def winner
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
138
|
+
Split.cache(:experiment_winner, name) do
|
139
|
+
experiment_winner = redis.hget(:experiment_winner, name)
|
140
|
+
if experiment_winner
|
141
|
+
Split::Alternative.new(experiment_winner, name)
|
142
|
+
else
|
143
|
+
nil
|
144
|
+
end
|
143
145
|
end
|
144
146
|
end
|
145
147
|
|
@@ -151,10 +153,11 @@ module Split
|
|
151
153
|
def winner=(winner_name)
|
152
154
|
redis.hset(:experiment_winner, name, winner_name.to_s)
|
153
155
|
@has_winner = true
|
156
|
+
Split.configuration.on_experiment_winner_choose.call(self)
|
154
157
|
end
|
155
158
|
|
156
159
|
def participant_count
|
157
|
-
alternatives.inject(0){|sum,a| sum + a.participant_count}
|
160
|
+
alternatives.inject(0){|sum, a| sum + a.participant_count}
|
158
161
|
end
|
159
162
|
|
160
163
|
def control
|
@@ -164,6 +167,7 @@ module Split
|
|
164
167
|
def reset_winner
|
165
168
|
redis.hdel(:experiment_winner, name)
|
166
169
|
@has_winner = false
|
170
|
+
Split::Cache.clear_key(@name)
|
167
171
|
end
|
168
172
|
|
169
173
|
def start
|
@@ -171,13 +175,15 @@ module Split
|
|
171
175
|
end
|
172
176
|
|
173
177
|
def start_time
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
178
|
+
Split.cache(:experiment_start_times, @name) do
|
179
|
+
t = redis.hget(:experiment_start_times, @name)
|
180
|
+
if t
|
181
|
+
# Check if stored time is an integer
|
182
|
+
if t =~ /^[-+]?[0-9]+$/
|
183
|
+
Time.at(t.to_i)
|
184
|
+
else
|
185
|
+
Time.parse(t)
|
186
|
+
end
|
181
187
|
end
|
182
188
|
end
|
183
189
|
end
|
@@ -228,6 +234,7 @@ module Split
|
|
228
234
|
|
229
235
|
def reset
|
230
236
|
Split.configuration.on_before_experiment_reset.call(self)
|
237
|
+
Split::Cache.clear_key(@name)
|
231
238
|
alternatives.each(&:reset)
|
232
239
|
reset_winner
|
233
240
|
Split.configuration.on_experiment_reset.call(self)
|
@@ -241,6 +248,7 @@ module Split
|
|
241
248
|
end
|
242
249
|
reset_winner
|
243
250
|
redis.srem(:experiments, name)
|
251
|
+
remove_experiment_cohorting
|
244
252
|
remove_experiment_configuration
|
245
253
|
Split.configuration.on_experiment_delete.call(self)
|
246
254
|
increment_version
|
@@ -338,7 +346,7 @@ module Split
|
|
338
346
|
|
339
347
|
def find_simulated_winner(simulated_cr_hash)
|
340
348
|
# figure out which alternative had the highest simulated conversion rate
|
341
|
-
winning_pair = ["",0.0]
|
349
|
+
winning_pair = ["", 0.0]
|
342
350
|
simulated_cr_hash.each do |alternative, rate|
|
343
351
|
if rate > winning_pair[1]
|
344
352
|
winning_pair = [alternative, rate]
|
@@ -349,17 +357,13 @@ module Split
|
|
349
357
|
end
|
350
358
|
|
351
359
|
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
360
|
simulated_cr_hash = {}
|
357
361
|
|
358
362
|
# create a hash which has the conversion rate pulled from each alternative's beta distribution
|
359
363
|
beta_params.each do |alternative, params|
|
360
364
|
alpha = params[0]
|
361
365
|
beta = params[1]
|
362
|
-
simulated_conversion_rate =
|
366
|
+
simulated_conversion_rate = Rubystats::BetaDistribution.new(alpha, beta).rng
|
363
367
|
simulated_cr_hash[alternative] = simulated_conversion_rate
|
364
368
|
end
|
365
369
|
|
@@ -397,6 +401,23 @@ module Split
|
|
397
401
|
js_id.gsub('/', '--')
|
398
402
|
end
|
399
403
|
|
404
|
+
def cohorting_disabled?
|
405
|
+
@cohorting_disabled ||= begin
|
406
|
+
value = redis.hget(experiment_config_key, :cohorting)
|
407
|
+
value.nil? ? false : value.downcase == "true"
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
def disable_cohorting
|
412
|
+
@cohorting_disabled = true
|
413
|
+
redis.hset(experiment_config_key, :cohorting, true)
|
414
|
+
end
|
415
|
+
|
416
|
+
def enable_cohorting
|
417
|
+
@cohorting_disabled = false
|
418
|
+
redis.hset(experiment_config_key, :cohorting, false)
|
419
|
+
end
|
420
|
+
|
400
421
|
protected
|
401
422
|
|
402
423
|
def experiment_config_key
|
@@ -423,15 +444,7 @@ module Split
|
|
423
444
|
end
|
424
445
|
|
425
446
|
def load_alternatives_from_redis
|
426
|
-
alternatives =
|
427
|
-
when 'set' # convert legacy sets to lists
|
428
|
-
alts = redis.smembers(@name)
|
429
|
-
redis.del(@name)
|
430
|
-
alts.reverse.each {|a| redis.lpush(@name, a) }
|
431
|
-
redis.lrange(@name, 0, -1)
|
432
|
-
else
|
433
|
-
redis.lrange(@name, 0, -1)
|
434
|
-
end
|
447
|
+
alternatives = redis.lrange(@name, 0, -1)
|
435
448
|
alternatives.map do |alt|
|
436
449
|
alt = begin
|
437
450
|
JSON.parse(alt)
|
@@ -456,7 +469,12 @@ module Split
|
|
456
469
|
redis_interface.add_to_set(:experiments, name)
|
457
470
|
redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
|
458
471
|
goals_collection.save
|
459
|
-
|
472
|
+
|
473
|
+
if @metadata
|
474
|
+
redis.set(metadata_key, @metadata.to_json)
|
475
|
+
else
|
476
|
+
delete_metadata
|
477
|
+
end
|
460
478
|
end
|
461
479
|
|
462
480
|
def remove_experiment_configuration
|
@@ -467,16 +485,20 @@ module Split
|
|
467
485
|
end
|
468
486
|
|
469
487
|
def experiment_configuration_has_changed?
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
existing_metadata != @metadata
|
488
|
+
existing_experiment = Experiment.find(@name)
|
489
|
+
|
490
|
+
existing_experiment.alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
|
491
|
+
existing_experiment.goals != @goals ||
|
492
|
+
existing_experiment.metadata != @metadata
|
476
493
|
end
|
477
494
|
|
478
495
|
def goals_collection
|
479
496
|
Split::GoalsCollection.new(@name, @goals)
|
480
497
|
end
|
498
|
+
|
499
|
+
def remove_experiment_cohorting
|
500
|
+
@cohorting_disabled = false
|
501
|
+
redis.hdel(experiment_config_key, :cohorting)
|
502
|
+
end
|
481
503
|
end
|
482
504
|
end
|
@@ -13,8 +13,7 @@ module Split
|
|
13
13
|
end
|
14
14
|
|
15
15
|
def self.find(name)
|
16
|
-
|
17
|
-
Experiment.new(name).tap { |exp| exp.load_from_redis }
|
16
|
+
Experiment.find(name)
|
18
17
|
end
|
19
18
|
|
20
19
|
def self.find_or_initialize(metric_descriptor, control = nil, *alternatives)
|
@@ -46,6 +45,5 @@ module Split
|
|
46
45
|
return experiment_name, goals
|
47
46
|
end
|
48
47
|
private_class_method :normalize_experiment
|
49
|
-
|
50
48
|
end
|
51
49
|
end
|
data/lib/split/helper.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Split
|
3
4
|
module Helper
|
4
5
|
OVERRIDE_PARAM_NAME = "ab_test"
|
@@ -32,8 +33,8 @@ module Split
|
|
32
33
|
end
|
33
34
|
|
34
35
|
if block_given?
|
35
|
-
metadata =
|
36
|
-
yield(alternative, metadata)
|
36
|
+
metadata = experiment.metadata[alternative] if experiment.metadata
|
37
|
+
yield(alternative, metadata || {})
|
37
38
|
else
|
38
39
|
alternative
|
39
40
|
end
|
@@ -51,9 +52,14 @@ module Split
|
|
51
52
|
return true
|
52
53
|
else
|
53
54
|
alternative_name = ab_user[experiment.key]
|
54
|
-
trial = Trial.new(
|
55
|
-
|
56
|
-
|
55
|
+
trial = Trial.new(
|
56
|
+
:user => ab_user,
|
57
|
+
:experiment => experiment,
|
58
|
+
:alternative => alternative_name,
|
59
|
+
:goals => options[:goals],
|
60
|
+
)
|
61
|
+
|
62
|
+
trial.complete!(self)
|
57
63
|
|
58
64
|
if should_reset
|
59
65
|
reset!(experiment)
|
@@ -70,6 +76,7 @@ module Split
|
|
70
76
|
|
71
77
|
if experiments.any?
|
72
78
|
experiments.each do |experiment|
|
79
|
+
next if override_present?(experiment.key)
|
73
80
|
finish_experiment(experiment, options.merge(:goals => goals))
|
74
81
|
end
|
75
82
|
end
|
@@ -105,15 +112,27 @@ module Split
|
|
105
112
|
Split.configuration.db_failover_on_db_error.call(e)
|
106
113
|
end
|
107
114
|
|
108
|
-
|
109
115
|
def override_present?(experiment_name)
|
110
|
-
|
116
|
+
override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
|
111
117
|
end
|
112
118
|
|
113
119
|
def override_alternative(experiment_name)
|
120
|
+
override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name)
|
121
|
+
end
|
122
|
+
|
123
|
+
def override_alternative_by_params(experiment_name)
|
114
124
|
defined?(params) && params[OVERRIDE_PARAM_NAME] && params[OVERRIDE_PARAM_NAME][experiment_name]
|
115
125
|
end
|
116
126
|
|
127
|
+
def override_alternative_by_cookies(experiment_name)
|
128
|
+
return unless defined?(request)
|
129
|
+
|
130
|
+
if request.cookies && request.cookies.key?('split_override')
|
131
|
+
experiments = JSON.parse(request.cookies['split_override']) rescue {}
|
132
|
+
experiments[experiment_name]
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
117
136
|
def split_generically_disabled?
|
118
137
|
defined?(params) && params['SPLIT_DISABLE']
|
119
138
|
end
|
data/lib/split/metric.rb
CHANGED
@@ -1,11 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Split
|
3
4
|
class Metric
|
4
5
|
attr_accessor :name
|
5
6
|
attr_accessor :experiments
|
6
7
|
|
7
8
|
def initialize(attrs = {})
|
8
|
-
attrs.each do |key,value|
|
9
|
+
attrs.each do |key, value|
|
9
10
|
if self.respond_to?("#{key}=")
|
10
11
|
self.send("#{key}=", value)
|
11
12
|
end
|
data/lib/split/persistence.rb
CHANGED
@@ -8,8 +8,10 @@ module Split
|
|
8
8
|
require 'split/persistence/session_adapter'
|
9
9
|
|
10
10
|
ADAPTERS = {
|
11
|
-
:
|
12
|
-
:
|
11
|
+
cookie: Split::Persistence::CookieAdapter,
|
12
|
+
session: Split::Persistence::SessionAdapter,
|
13
|
+
redis: Split::Persistence::RedisAdapter,
|
14
|
+
dual_adapter: Split::Persistence::DualAdapter
|
13
15
|
}.freeze
|
14
16
|
|
15
17
|
def self.adapter
|
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module Split
|
3
4
|
module Persistence
|
4
5
|
class RedisAdapter
|
@@ -39,6 +40,10 @@ module Split
|
|
39
40
|
Split.redis.hkeys(redis_key)
|
40
41
|
end
|
41
42
|
|
43
|
+
def self.find(user_id)
|
44
|
+
new(nil, user_id)
|
45
|
+
end
|
46
|
+
|
42
47
|
def self.with_config(options={})
|
43
48
|
self.config.merge!(options)
|
44
49
|
self
|