split 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,14 +1,20 @@
1
1
  module Split
2
2
  module Helper
3
3
 
4
- def ab_test(experiment_name, control=nil, *alternatives)
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
- load_and_start_trial(experiment_name, control, alternatives)
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
- if ret.nil?
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 => 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(metric_name, options = {:reset => true})
70
+ def finished(metric_descriptor, options = {:reset => true})
64
71
  return if exclude_visitor? || Split.configuration.disabled?
65
- experiments = Metric.possible_experiments(metric_name)
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
- is_robot? || is_ignored_ip_address?
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.any?
128
- Split.configuration.ignore_ip_addresses.include?(request.ip)
129
- else
130
- false
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 control_variable(control)
137
- Hash === control ? control.keys.first : control
138
- end
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
- experiment = Split::Experiment.find_or_create(experiment_name, *([control] + alternatives))
150
+ experiment_name = metric_descriptor
151
+ goals = []
147
152
  end
153
+ return experiment_name, goals
154
+ end
148
155
 
149
- start_trial( Trial.new(:experiment => experiment) )
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)
@@ -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
- @cookies[:split] ? JSON.parse(@cookies[:split]) : {}
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
@@ -1,12 +1,12 @@
1
1
  module Split
2
2
  class Trial
3
3
  attr_accessor :experiment
4
- attr_writer :alternative
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.alternative_name = attrs[:alternative_name] if !attrs[:alternative_name].nil?
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
- alternative.increment_completion if alternative
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
- if experiment.winner
33
- self.alternative = experiment.winner
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 = experiment.next_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
@@ -1,6 +1,6 @@
1
1
  module Split
2
- MAJOR = 0
3
- MINOR = 5
4
- PATCH = 0
2
+ MAJOR = 0
3
+ MINOR = 6
4
+ PATCH = 0
5
5
  VERSION = [MAJOR, MINOR, PATCH].join('.')
6
6
  end
@@ -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', :alternative_names => [{'Basket' => 0.6}, {"Cart" => 0.4}])
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.find(:my_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
- # it "accepts probability on some alternatives" do
52
- # Split.configuration.experiments[:my_experiment] = {
53
- # :alternatives => [
54
- # { :name => "control_opt", :percent => 34 },
55
- # "second_opt",
56
- # { :name => "third_opt", :percent => 23 },
57
- # "fourth_opt",
58
- # ],
59
- # }
60
- # should start_experiment(:my_experiment).with({"control_opt" => 0.34}, {"second_opt" => 0.215}, {"third_opt" => 0.23}, {"fourth_opt" => 0.215})
61
- # ab_test :my_experiment
62
- # end
63
- #
64
- # it "allows name param without probability" do
65
- # Split.configuration.experiments[:my_experiment] = {
66
- # :alternatives => [
67
- # { :name => "control_opt" },
68
- # "second_opt",
69
- # { :name => "third_opt", :percent => 64 },
70
- # ],
71
- # }
72
- # should start_experiment(:my_experiment).with({"control_opt" => 0.18}, {"second_opt" => 0.18}, {"third_opt" => 0.64})
73
- # ab_test :my_experiment
74
- # end
75
-
76
- it "should set the weights from a configuration file" do
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
- experiment = Split::Experiment.new('basket_text', :alternative_names => ['Basket', "Cart"])
117
- experiment.save
118
- alternative = Split::Alternative.new('Basket', 'basket_text')
119
- old_completed_count = alternative.participant_count
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.completed_count.should eql(old_completed_count+1)
153
+ alternative.increment_completion(goal1)
154
+ alternative.increment_completion(goal2)
122
155
 
123
- Split::Alternative.new('Basket', 'basket_text').completed_count.should eql(old_completed_count+1)
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.completed_count = 4
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
- alternative = Split::Alternative.new('Cart', 'basket_text')
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
- experiment = Split::Experiment.find_or_create('link_color', 'blue', 'red')
172
-
173
- alternative = Split::Alternative.new('red', 'link_color')
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