split 0.5.0 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +1 -3
- data/CHANGELOG.mdown +25 -6
- data/Gemfile +1 -1
- data/README.mdown +137 -52
- data/lib/split.rb +1 -1
- data/lib/split/alternative.rb +54 -26
- data/lib/split/configuration.rb +94 -30
- data/lib/split/dashboard/public/style.css +10 -2
- data/lib/split/dashboard/views/_experiment.erb +31 -22
- data/lib/split/dashboard/views/_experiment_with_goal_header.erb +14 -0
- data/lib/split/dashboard/views/index.erb +8 -1
- data/lib/split/experiment.rb +166 -146
- data/lib/split/helper.rb +33 -23
- data/lib/split/metric.rb +13 -0
- data/lib/split/persistence/cookie_adapter.rb +10 -2
- data/lib/split/trial.rb +18 -12
- data/lib/split/version.rb +3 -3
- data/spec/alternative_spec.rb +121 -78
- data/spec/configuration_spec.rb +92 -4
- data/spec/dashboard_spec.rb +27 -11
- data/spec/experiment_spec.rb +110 -67
- data/spec/helper_spec.rb +287 -104
- data/spec/persistence/cookie_adapter_spec.rb +8 -1
- data/spec/spec_helper.rb +1 -0
- data/spec/trial_spec.rb +10 -8
- data/split.gemspec +1 -1
- metadata +19 -18
data/lib/split/helper.rb
CHANGED
@@ -1,14 +1,20 @@
|
|
1
1
|
module Split
|
2
2
|
module Helper
|
3
3
|
|
4
|
-
def ab_test(
|
4
|
+
def ab_test(metric_descriptor, control=nil, *alternatives)
|
5
5
|
if RUBY_VERSION.match(/1\.8/) && alternatives.length.zero? && ! control.nil?
|
6
6
|
puts 'WARNING: You should always pass the control alternative through as the second argument with any other alternatives as the third because the order of the hash is not preserved in ruby 1.8'
|
7
7
|
end
|
8
8
|
|
9
9
|
begin
|
10
|
+
experiment_name_with_version, goals = normalize_experiment(metric_descriptor)
|
11
|
+
experiment_name = experiment_name_with_version.to_s.split(':')[0]
|
12
|
+
experiment = Split::Experiment.new(experiment_name, :alternatives => [control].compact + alternatives, :goals => goals)
|
13
|
+
control ||= experiment.control && experiment.control.name
|
14
|
+
|
10
15
|
ret = if Split.configuration.enabled
|
11
|
-
|
16
|
+
experiment.save
|
17
|
+
start_trial( Trial.new(:experiment => experiment) )
|
12
18
|
else
|
13
19
|
control_variable(control)
|
14
20
|
end
|
@@ -21,7 +27,7 @@ module Split
|
|
21
27
|
ret = override_alternative(experiment_name)
|
22
28
|
end
|
23
29
|
ensure
|
24
|
-
|
30
|
+
unless ret
|
25
31
|
ret = control_variable(control)
|
26
32
|
end
|
27
33
|
end
|
@@ -44,12 +50,13 @@ module Split
|
|
44
50
|
end
|
45
51
|
|
46
52
|
def finish_experiment(experiment, options = {:reset => true})
|
53
|
+
return true unless experiment.winner.nil?
|
47
54
|
should_reset = experiment.resettable? && options[:reset]
|
48
55
|
if ab_user[experiment.finished_key] && !should_reset
|
49
56
|
return true
|
50
57
|
else
|
51
58
|
alternative_name = ab_user[experiment.key]
|
52
|
-
trial = Trial.new(:experiment => experiment, :alternative_name =>
|
59
|
+
trial = Trial.new(:experiment => experiment, :alternative => alternative_name, :goals => options[:goals])
|
53
60
|
trial.complete!
|
54
61
|
if should_reset
|
55
62
|
reset!(experiment)
|
@@ -60,13 +67,14 @@ module Split
|
|
60
67
|
end
|
61
68
|
|
62
69
|
|
63
|
-
def finished(
|
70
|
+
def finished(metric_descriptor, options = {:reset => true})
|
64
71
|
return if exclude_visitor? || Split.configuration.disabled?
|
65
|
-
|
72
|
+
metric_descriptor, goals = normalize_experiment(metric_descriptor)
|
73
|
+
experiments = Metric.possible_experiments(metric_descriptor)
|
66
74
|
|
67
75
|
if experiments.any?
|
68
76
|
experiments.each do |experiment|
|
69
|
-
finish_experiment(experiment, options)
|
77
|
+
finish_experiment(experiment, options.merge(:goals => goals))
|
70
78
|
end
|
71
79
|
end
|
72
80
|
rescue => e
|
@@ -93,7 +101,7 @@ module Split
|
|
93
101
|
end
|
94
102
|
|
95
103
|
def exclude_visitor?
|
96
|
-
|
104
|
+
instance_eval(&Split.configuration.ignore_filter)
|
97
105
|
end
|
98
106
|
|
99
107
|
def not_allowed_to_test?(experiment_key)
|
@@ -124,35 +132,37 @@ module Split
|
|
124
132
|
end
|
125
133
|
|
126
134
|
def is_ignored_ip_address?
|
127
|
-
if Split.configuration.ignore_ip_addresses.
|
128
|
-
|
129
|
-
|
130
|
-
|
135
|
+
return false if Split.configuration.ignore_ip_addresses.empty?
|
136
|
+
|
137
|
+
Split.configuration.ignore_ip_addresses.each do |ip|
|
138
|
+
return true if request.ip == ip || (ip.class == Regexp && request.ip =~ ip)
|
131
139
|
end
|
140
|
+
false
|
132
141
|
end
|
133
142
|
|
134
143
|
protected
|
135
144
|
|
136
|
-
def
|
137
|
-
Hash ===
|
138
|
-
|
139
|
-
|
140
|
-
def load_and_start_trial(experiment_name, control, alternatives)
|
141
|
-
if control.nil? && alternatives.length.zero?
|
142
|
-
experiment = Experiment.find(experiment_name)
|
143
|
-
|
144
|
-
raise ExperimentNotFound.new("#{experiment_name} not found") if experiment.nil?
|
145
|
+
def normalize_experiment(metric_descriptor)
|
146
|
+
if Hash === metric_descriptor
|
147
|
+
experiment_name = metric_descriptor.keys.first
|
148
|
+
goals = Array(metric_descriptor.values.first)
|
145
149
|
else
|
146
|
-
|
150
|
+
experiment_name = metric_descriptor
|
151
|
+
goals = []
|
147
152
|
end
|
153
|
+
return experiment_name, goals
|
154
|
+
end
|
148
155
|
|
149
|
-
|
156
|
+
def control_variable(control)
|
157
|
+
Hash === control ? control.keys.first : control
|
150
158
|
end
|
151
159
|
|
152
160
|
def start_trial(trial)
|
153
161
|
experiment = trial.experiment
|
154
162
|
if override_present?(experiment.name)
|
155
163
|
ret = override_alternative(experiment.name)
|
164
|
+
elsif ! experiment.winner.nil?
|
165
|
+
ret = experiment.winner.name
|
156
166
|
else
|
157
167
|
clean_old_versions(experiment)
|
158
168
|
if exclude_visitor? || not_allowed_to_test?(experiment.key)
|
data/lib/split/metric.rb
CHANGED
@@ -64,5 +64,18 @@ module Split
|
|
64
64
|
experiment.complete!
|
65
65
|
end
|
66
66
|
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def self.normalize_metric(label)
|
71
|
+
if Hash === label
|
72
|
+
metric_name = label.keys.first
|
73
|
+
goals = label.values.first
|
74
|
+
else
|
75
|
+
metric_name = label
|
76
|
+
goals = []
|
77
|
+
end
|
78
|
+
return metric_name, goals
|
79
|
+
end
|
67
80
|
end
|
68
81
|
end
|
@@ -36,9 +36,17 @@ module Split
|
|
36
36
|
end
|
37
37
|
|
38
38
|
def hash
|
39
|
-
|
39
|
+
if @cookies[:split]
|
40
|
+
begin
|
41
|
+
JSON.parse(@cookies[:split])
|
42
|
+
rescue JSON::ParserError
|
43
|
+
{}
|
44
|
+
end
|
45
|
+
else
|
46
|
+
{}
|
47
|
+
end
|
40
48
|
end
|
41
49
|
|
42
50
|
end
|
43
51
|
end
|
44
|
-
end
|
52
|
+
end
|
data/lib/split/trial.rb
CHANGED
@@ -1,12 +1,12 @@
|
|
1
1
|
module Split
|
2
2
|
class Trial
|
3
3
|
attr_accessor :experiment
|
4
|
-
|
4
|
+
attr_accessor :goals
|
5
5
|
|
6
6
|
def initialize(attrs = {})
|
7
7
|
self.experiment = attrs[:experiment] if !attrs[:experiment].nil?
|
8
8
|
self.alternative = attrs[:alternative] if !attrs[:alternative].nil?
|
9
|
-
self.
|
9
|
+
self.goals = attrs[:goals] if !attrs[:goals].nil?
|
10
10
|
end
|
11
11
|
|
12
12
|
def alternative
|
@@ -16,28 +16,34 @@ module Split
|
|
16
16
|
end
|
17
17
|
|
18
18
|
def complete!
|
19
|
-
|
19
|
+
if alternative
|
20
|
+
if self.goals.empty?
|
21
|
+
alternative.increment_completion
|
22
|
+
else
|
23
|
+
self.goals.each {|g| alternative.increment_completion(g)}
|
24
|
+
end
|
25
|
+
end
|
20
26
|
end
|
21
27
|
|
22
28
|
def choose!
|
23
29
|
choose
|
24
30
|
record!
|
25
31
|
end
|
26
|
-
|
32
|
+
|
27
33
|
def record!
|
28
34
|
alternative.increment_participation
|
29
35
|
end
|
30
36
|
|
31
37
|
def choose
|
32
|
-
|
33
|
-
|
38
|
+
self.alternative = experiment.next_alternative
|
39
|
+
end
|
40
|
+
|
41
|
+
def alternative=(alternative)
|
42
|
+
@alternative = if alternative.kind_of?(Split::Alternative)
|
43
|
+
alternative
|
34
44
|
else
|
35
|
-
self.alternative
|
45
|
+
self.experiment.alternatives.find{|a| a.name == alternative }
|
36
46
|
end
|
37
47
|
end
|
38
|
-
|
39
|
-
def alternative_name=(name)
|
40
|
-
self.alternative= self.experiment.alternatives.find{|a| a.name == name }
|
41
|
-
end
|
42
48
|
end
|
43
|
-
end
|
49
|
+
end
|
data/lib/split/version.rb
CHANGED
data/spec/alternative_spec.rb
CHANGED
@@ -3,31 +3,51 @@ require 'split/alternative'
|
|
3
3
|
|
4
4
|
describe Split::Alternative do
|
5
5
|
|
6
|
+
let(:alternative) {
|
7
|
+
Split::Alternative.new('Basket', 'basket_text')
|
8
|
+
}
|
9
|
+
|
10
|
+
let(:alternative2) {
|
11
|
+
Split::Alternative.new('Cart', 'basket_text')
|
12
|
+
}
|
13
|
+
|
14
|
+
let(:experiment) {
|
15
|
+
Split::Experiment.find_or_create({"basket_text" => ["purchase", "refund"]}, "Basket", "Cart")
|
16
|
+
}
|
17
|
+
|
18
|
+
let(:goal1) { "purchase" }
|
19
|
+
let(:goal2) { "refund" }
|
20
|
+
|
21
|
+
# setup experiment
|
22
|
+
before do
|
23
|
+
experiment
|
24
|
+
end
|
25
|
+
|
26
|
+
it "should have goals" do
|
27
|
+
alternative.goals.should eql(["purchase", "refund"])
|
28
|
+
end
|
29
|
+
|
6
30
|
it "should have a name" do
|
7
|
-
experiment = Split::Experiment.new('basket_text', :alternative_names => ['Basket', "Cart"])
|
8
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
9
31
|
alternative.name.should eql('Basket')
|
10
32
|
end
|
11
33
|
|
12
34
|
it "return only the name" do
|
13
|
-
experiment = Split::Experiment.new('basket_text', :alternative_names => [{'Basket' => 0.6}, {"Cart" => 0.4}])
|
14
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
15
35
|
alternative.name.should eql('Basket')
|
16
36
|
end
|
17
|
-
|
37
|
+
|
18
38
|
describe 'weights' do
|
19
|
-
|
39
|
+
|
20
40
|
it "should set the weights" do
|
21
|
-
experiment = Split::Experiment.new('basket_text', :
|
41
|
+
experiment = Split::Experiment.new('basket_text', :alternatives => [{'Basket' => 0.6}, {"Cart" => 0.4}])
|
22
42
|
first = experiment.alternatives[0]
|
23
43
|
first.name.should == 'Basket'
|
24
44
|
first.weight.should == 0.6
|
25
|
-
|
45
|
+
|
26
46
|
second = experiment.alternatives[1]
|
27
47
|
second.name.should == 'Cart'
|
28
|
-
second.weight.should == 0.4
|
48
|
+
second.weight.should == 0.4
|
29
49
|
end
|
30
|
-
|
50
|
+
|
31
51
|
it "accepts probability on alternatives" do
|
32
52
|
Split.configuration.experiments = {
|
33
53
|
:my_experiment => {
|
@@ -35,150 +55,173 @@ describe Split::Alternative do
|
|
35
55
|
{ :name => "control_opt", :percent => 67 },
|
36
56
|
{ :name => "second_opt", :percent => 10 },
|
37
57
|
{ :name => "third_opt", :percent => 23 },
|
38
|
-
]
|
58
|
+
]
|
39
59
|
}
|
40
60
|
}
|
41
|
-
experiment = Split::Experiment.
|
61
|
+
experiment = Split::Experiment.new(:my_experiment)
|
42
62
|
first = experiment.alternatives[0]
|
43
63
|
first.name.should == 'control_opt'
|
44
64
|
first.weight.should == 0.67
|
45
|
-
|
65
|
+
|
46
66
|
second = experiment.alternatives[1]
|
47
67
|
second.name.should == 'second_opt'
|
48
68
|
second.weight.should == 0.1
|
49
69
|
end
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
70
|
+
|
71
|
+
it "accepts probability on some alternatives" do
|
72
|
+
Split.configuration.experiments = {
|
73
|
+
:my_experiment => {
|
74
|
+
:alternatives => [
|
75
|
+
{ :name => "control_opt", :percent => 34 },
|
76
|
+
"second_opt",
|
77
|
+
{ :name => "third_opt", :percent => 23 },
|
78
|
+
"fourth_opt",
|
79
|
+
],
|
80
|
+
}
|
81
|
+
}
|
82
|
+
experiment = Split::Experiment.new(:my_experiment)
|
83
|
+
alts = experiment.alternatives
|
84
|
+
[
|
85
|
+
["control_opt", 0.34],
|
86
|
+
["second_opt", 0.215],
|
87
|
+
["third_opt", 0.23],
|
88
|
+
["fourth_opt", 0.215]
|
89
|
+
].each do |h|
|
90
|
+
name, weight = h
|
91
|
+
alt = alts.shift
|
92
|
+
alt.name.should == name
|
93
|
+
alt.weight.should == weight
|
94
|
+
end
|
95
|
+
end
|
96
|
+
#
|
97
|
+
it "allows name param without probability" do
|
98
|
+
Split.configuration.experiments = {
|
99
|
+
:my_experiment => {
|
100
|
+
:alternatives => [
|
101
|
+
{ :name => "control_opt" },
|
102
|
+
"second_opt",
|
103
|
+
{ :name => "third_opt", :percent => 64 },
|
104
|
+
],
|
105
|
+
}
|
106
|
+
}
|
107
|
+
experiment = Split::Experiment.new(:my_experiment)
|
108
|
+
alts = experiment.alternatives
|
109
|
+
[
|
110
|
+
["control_opt", 0.18],
|
111
|
+
["second_opt", 0.18],
|
112
|
+
["third_opt", 0.64],
|
113
|
+
].each do |h|
|
114
|
+
name, weight = h
|
115
|
+
alt = alts.shift
|
116
|
+
alt.name.should == name
|
117
|
+
alt.weight.should == weight
|
118
|
+
end
|
78
119
|
end
|
79
120
|
end
|
80
121
|
|
81
122
|
it "should have a default participation count of 0" do
|
82
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
83
123
|
alternative.participant_count.should eql(0)
|
84
124
|
end
|
85
125
|
|
86
|
-
it "should have a default completed count of 0" do
|
87
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
126
|
+
it "should have a default completed count of 0 for each goal" do
|
88
127
|
alternative.completed_count.should eql(0)
|
128
|
+
alternative.completed_count(goal1).should eql(0)
|
129
|
+
alternative.completed_count(goal2).should eql(0)
|
89
130
|
end
|
90
131
|
|
91
132
|
it "should belong to an experiment" do
|
92
|
-
experiment = Split::Experiment.new('basket_text', :alternative_names => ['Basket', "Cart"])
|
93
|
-
experiment.save
|
94
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
95
133
|
alternative.experiment.name.should eql(experiment.name)
|
96
134
|
end
|
97
135
|
|
98
136
|
it "should save to redis" do
|
99
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
100
137
|
alternative.save
|
101
138
|
Split.redis.exists('basket_text:Basket').should be true
|
102
139
|
end
|
103
140
|
|
104
141
|
it "should increment participation count" do
|
105
|
-
experiment = Split::Experiment.new('basket_text', :alternative_names => ['Basket', "Cart"])
|
106
|
-
experiment.save
|
107
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
108
142
|
old_participant_count = alternative.participant_count
|
109
143
|
alternative.increment_participation
|
110
144
|
alternative.participant_count.should eql(old_participant_count+1)
|
111
|
-
|
112
|
-
Split::Alternative.new('Basket', 'basket_text').participant_count.should eql(old_participant_count+1)
|
113
145
|
end
|
114
146
|
|
115
|
-
it "should increment completed count" do
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
147
|
+
it "should increment completed count for each goal" do
|
148
|
+
old_default_completed_count = alternative.completed_count
|
149
|
+
old_completed_count_for_goal1 = alternative.completed_count(goal1)
|
150
|
+
old_completed_count_for_goal2 = alternative.completed_count(goal2)
|
151
|
+
|
120
152
|
alternative.increment_completion
|
121
|
-
alternative.
|
153
|
+
alternative.increment_completion(goal1)
|
154
|
+
alternative.increment_completion(goal2)
|
122
155
|
|
123
|
-
|
156
|
+
alternative.completed_count.should eql(old_default_completed_count+1)
|
157
|
+
alternative.completed_count(goal1).should eql(old_completed_count_for_goal1+1)
|
158
|
+
alternative.completed_count(goal2).should eql(old_completed_count_for_goal2+1)
|
124
159
|
end
|
125
160
|
|
126
161
|
it "can be reset" do
|
127
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
128
162
|
alternative.participant_count = 10
|
129
|
-
alternative.
|
163
|
+
alternative.set_completed_count(4, goal1)
|
164
|
+
alternative.set_completed_count(5, goal2)
|
165
|
+
alternative.set_completed_count(6)
|
130
166
|
alternative.reset
|
131
167
|
alternative.participant_count.should eql(0)
|
168
|
+
alternative.completed_count(goal1).should eql(0)
|
169
|
+
alternative.completed_count(goal2).should eql(0)
|
132
170
|
alternative.completed_count.should eql(0)
|
133
171
|
end
|
134
172
|
|
135
173
|
it "should know if it is the control of an experiment" do
|
136
|
-
experiment = Split::Experiment.new('basket_text', :alternative_names => ['Basket', "Cart"])
|
137
|
-
experiment.save
|
138
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
139
174
|
alternative.control?.should be_true
|
140
|
-
|
141
|
-
alternative.control?.should be_false
|
175
|
+
alternative2.control?.should be_false
|
142
176
|
end
|
143
177
|
|
144
178
|
describe 'unfinished_count' do
|
145
179
|
it "should be difference between participant and completed counts" do
|
146
|
-
experiment = Split::Experiment.new('basket_text', :alternative_names => ['Basket', "Cart"])
|
147
|
-
experiment.save
|
148
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
149
180
|
alternative.increment_participation
|
150
181
|
alternative.unfinished_count.should eql(alternative.participant_count)
|
151
182
|
end
|
183
|
+
|
184
|
+
it "should return the correct unfinished_count" do
|
185
|
+
alternative.participant_count = 10
|
186
|
+
alternative.set_completed_count(4, goal1)
|
187
|
+
alternative.set_completed_count(3, goal2)
|
188
|
+
alternative.set_completed_count(2)
|
189
|
+
|
190
|
+
alternative.unfinished_count.should eql(1)
|
191
|
+
end
|
152
192
|
end
|
153
193
|
|
154
194
|
describe 'conversion rate' do
|
155
195
|
it "should be 0 if there are no conversions" do
|
156
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
157
196
|
alternative.completed_count.should eql(0)
|
158
197
|
alternative.conversion_rate.should eql(0)
|
159
198
|
end
|
160
199
|
|
161
200
|
it "calculate conversion rate" do
|
162
|
-
alternative = Split::Alternative.new('Basket', 'basket_text')
|
163
201
|
alternative.stub(:participant_count).and_return(10)
|
164
202
|
alternative.stub(:completed_count).and_return(4)
|
165
203
|
alternative.conversion_rate.should eql(0.4)
|
204
|
+
|
205
|
+
alternative.stub(:completed_count).with(goal1).and_return(5)
|
206
|
+
alternative.conversion_rate(goal1).should eql(0.5)
|
207
|
+
|
208
|
+
alternative.stub(:completed_count).with(goal2).and_return(6)
|
209
|
+
alternative.conversion_rate(goal2).should eql(0.6)
|
166
210
|
end
|
167
211
|
end
|
168
212
|
|
169
213
|
describe 'z score' do
|
170
214
|
it 'should be zero when the control has no conversions' do
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
alternative.z_score.should eql(0)
|
215
|
+
alternative2.z_score.should eql(0)
|
216
|
+
alternative2.z_score(goal1).should eql(0)
|
217
|
+
alternative2.z_score(goal2).should eql(0)
|
175
218
|
end
|
176
219
|
|
177
220
|
it "should be N/A for the control" do
|
178
|
-
experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
|
179
|
-
|
180
221
|
control = experiment.control
|
181
222
|
control.z_score.should eql('N/A')
|
223
|
+
control.z_score(goal1).should eql('N/A')
|
224
|
+
control.z_score(goal2).should eql('N/A')
|
182
225
|
end
|
183
226
|
end
|
184
227
|
end
|