split 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/split/metric.rb CHANGED
@@ -17,7 +17,7 @@ module Split
17
17
  experiment_names = metric.split(',')
18
18
 
19
19
  experiments = experiment_names.collect do |experiment_name|
20
- Split::Experiment.find(experiment_name)
20
+ Split::ExperimentCatalog.find(experiment_name)
21
21
  end
22
22
 
23
23
  Split::Metric.new(:name => name, :experiments => experiments)
@@ -67,7 +67,7 @@ module Split
67
67
  if metric
68
68
  experiments << metric.experiments
69
69
  end
70
- experiment = Split::Experiment.find(metric_name)
70
+ experiment = Split::ExperimentCatalog.find(metric_name)
71
71
  if experiment
72
72
  experiments << experiment
73
73
  end
@@ -5,8 +5,10 @@ module Split
5
5
 
6
6
  attr_reader :redis_key
7
7
 
8
- def initialize(context)
9
- if lookup_by = self.class.config[:lookup_by]
8
+ def initialize(context, key = nil)
9
+ if key
10
+ @redis_key = "#{self.class.config[:namespace]}:#{key}"
11
+ elsif lookup_by = self.class.config[:lookup_by]
10
12
  if lookup_by.respond_to?(:call)
11
13
  key_frag = lookup_by.call(context)
12
14
  else
data/lib/split/trial.rb CHANGED
@@ -1,49 +1,111 @@
1
1
  module Split
2
2
  class Trial
3
3
  attr_accessor :experiment
4
- attr_accessor :goals
5
4
 
6
5
  def initialize(attrs = {})
7
- self.experiment = attrs[:experiment] if !attrs[:experiment].nil?
8
- self.alternative = attrs[:alternative] if !attrs[:alternative].nil?
9
- self.goals = attrs[:goals].nil? ? [] : attrs[:goals]
6
+ self.experiment = attrs.delete(:experiment)
7
+ self.alternative = attrs.delete(:alternative)
8
+
9
+ @user = attrs.delete(:user)
10
+ @options = attrs
11
+
12
+ @alternative_choosen = false
10
13
  end
11
14
 
12
15
  def alternative
13
- @alternative ||= if experiment.has_winner?
14
- experiment.winner
16
+ @alternative ||= if @experiment.has_winner?
17
+ @experiment.winner
15
18
  end
16
19
  end
17
20
 
18
- def complete!
21
+ def alternative=(alternative)
22
+ @alternative = if alternative.kind_of?(Split::Alternative)
23
+ alternative
24
+ else
25
+ @experiment.alternatives.find{|a| a.name == alternative }
26
+ end
27
+ end
28
+
29
+ def complete!(goals, context = nil)
30
+ goals = goals || []
31
+
19
32
  if alternative
20
- if self.goals.empty?
33
+ if goals.empty?
21
34
  alternative.increment_completion
22
35
  else
23
- self.goals.each {|g| alternative.increment_completion(g)}
36
+ goals.each {|g| alternative.increment_completion(g) }
24
37
  end
38
+
39
+ context.send(Split.configuration.on_trial_complete, self) \
40
+ if Split.configuration.on_trial_complete && context
25
41
  end
26
42
  end
27
43
 
28
- def choose!
29
- choose
30
- record!
31
- end
44
+ # Choose an alternative, add a participant, and save the alternative choice on the user. This
45
+ # method is guaranteed to only run once, and will skip the alternative choosing process if run
46
+ # a second time.
47
+ def choose!(context = nil)
48
+ # Only run the process once
49
+ return alternative if @alternative_choosen
32
50
 
33
- def record!
34
- alternative.increment_participation
35
- end
51
+ if @options[:override]
52
+ self.alternative = @options[:override]
53
+ elsif @options[:disabled] || !Split.configuration.enabled
54
+ self.alternative = @experiment.control
55
+ elsif @experiment.has_winner?
56
+ self.alternative = @experiment.winner
57
+ else
58
+ cleanup_old_versions
59
+
60
+ if exclude_user?
61
+ self.alternative = @experiment.control
62
+ elsif @user[@experiment.key]
63
+ self.alternative = @user[@experiment.key]
64
+ else
65
+ self.alternative = @experiment.next_alternative
66
+
67
+ # Increment the number of participants since we are actually choosing a new alternative
68
+ self.alternative.increment_participation
36
69
 
37
- def choose
38
- self.alternative = experiment.next_alternative
70
+ # Run the post-choosing hook on the context
71
+ context.send(Split.configuration.on_trial_choose, self) \
72
+ if Split.configuration.on_trial_choose && context
73
+ end
74
+ end
75
+
76
+ @user[@experiment.key] = alternative.name if should_store_alternative?
77
+ @alternative_choosen = true
78
+ alternative
39
79
  end
40
80
 
41
- def alternative=(alternative)
42
- @alternative = if alternative.kind_of?(Split::Alternative)
43
- alternative
81
+ private
82
+
83
+ def should_store_alternative?
84
+ if @options[:override] || @options[:disabled]
85
+ Split.configuration.store_override
44
86
  else
45
- self.experiment.alternatives.find{|a| a.name == alternative }
87
+ !exclude_user?
88
+ end
89
+ end
90
+
91
+ def cleanup_old_versions
92
+ if @experiment.version > 0
93
+ keys = @user.keys.select { |k| k.match(Regexp.new(@experiment.name)) }
94
+ keys_without_experiment(keys).each { |key| @user.delete(key) }
46
95
  end
47
96
  end
97
+
98
+ def exclude_user?
99
+ @options[:exclude] || @experiment.start_time.nil? || max_experiments_reached?
100
+ end
101
+
102
+ def max_experiments_reached?
103
+ !Split.configuration.allow_multiple_experiments &&
104
+ keys_without_experiment(@user.keys).length > 0
105
+ end
106
+
107
+ def keys_without_experiment(keys)
108
+ keys.reject { |k| k.match(Regexp.new("^#{@experiment.key}(:finished)?$")) }
109
+ end
48
110
  end
49
111
  end
data/lib/split/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Split
2
2
  MAJOR = 1
3
- MINOR = 0
3
+ MINOR = 1
4
4
  PATCH = 0
5
5
  VERSION = [MAJOR, MINOR, PATCH].join('.')
6
6
  end
@@ -2,17 +2,17 @@ require "spec_helper"
2
2
 
3
3
  describe Split::Algorithms::WeightedSample do
4
4
  it "should return an alternative" do
5
- experiment = Split::Experiment.find_or_create('link_color', {'blue' => 100}, {'red' => 0 })
5
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 100}, {'red' => 0 })
6
6
  expect(Split::Algorithms::WeightedSample.choose_alternative(experiment).class).to eq(Split::Alternative)
7
7
  end
8
8
 
9
9
  it "should always return a heavily weighted option" do
10
- experiment = Split::Experiment.find_or_create('link_color', {'blue' => 100}, {'red' => 0 })
10
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 100}, {'red' => 0 })
11
11
  expect(Split::Algorithms::WeightedSample.choose_alternative(experiment).name).to eq('blue')
12
12
  end
13
13
 
14
14
  it "should return one of the results" do
15
- experiment = Split::Experiment.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
15
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
16
16
  expect(['red', 'blue']).to include Split::Algorithms::WeightedSample.choose_alternative(experiment).name
17
17
  end
18
18
  end
@@ -3,12 +3,12 @@ require "spec_helper"
3
3
  describe Split::Algorithms::Whiplash do
4
4
 
5
5
  it "should return an algorithm" do
6
- experiment = Split::Experiment.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
6
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
7
7
  expect(Split::Algorithms::Whiplash.choose_alternative(experiment).class).to eq(Split::Alternative)
8
8
  end
9
9
 
10
10
  it "should return one of the results" do
11
- experiment = Split::Experiment.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
11
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
12
12
  expect(['red', 'blue']).to include Split::Algorithms::Whiplash.choose_alternative(experiment).name
13
13
  end
14
14
 
@@ -12,7 +12,7 @@ describe Split::Alternative do
12
12
  }
13
13
 
14
14
  let!(:experiment) {
15
- Split::Experiment.find_or_create({"basket_text" => ["purchase", "refund"]}, "Basket", "Cart")
15
+ Split::ExperimentCatalog.find_or_create({"basket_text" => ["purchase", "refund"]}, "Basket", "Cart")
16
16
  }
17
17
 
18
18
  let(:goal1) { "purchase" }
@@ -14,11 +14,11 @@ describe Split::Dashboard do
14
14
  end
15
15
 
16
16
  let(:experiment) {
17
- Split::Experiment.find_or_create("link_color", "blue", "red")
17
+ Split::ExperimentCatalog.find_or_create("link_color", "blue", "red")
18
18
  }
19
19
 
20
20
  let(:experiment_with_goals) {
21
- Split::Experiment.find_or_create({"link_color" => ["goal_1", "goal_2"]}, "blue", "red")
21
+ Split::ExperimentCatalog.find_or_create({"link_color" => ["goal_1", "goal_2"]}, "blue", "red")
22
22
  }
23
23
 
24
24
  let(:metric) {
@@ -139,7 +139,7 @@ describe Split::Dashboard do
139
139
  it "should delete an experiment" do
140
140
  delete "/#{experiment.name}"
141
141
  expect(last_response).to be_redirect
142
- expect(Split::Experiment.find(experiment.name)).to be_nil
142
+ expect(Split::ExperimentCatalog.find(experiment.name)).to be_nil
143
143
  end
144
144
 
145
145
  it "should mark an alternative as the winner" do
@@ -4,12 +4,8 @@ describe Split::EncapsulatedHelper do
4
4
  include Split::EncapsulatedHelper
5
5
 
6
6
  before do
7
- @persistence_adapter = Split.configuration.persistence
8
- Split.configuration.persistence = Hash
9
- end
10
-
11
- after do
12
- Split.configuration.persistence = @persistence_adapter
7
+ allow_any_instance_of(Split::EncapsulatedHelper::ContextShim).to receive(:ab_user)
8
+ .and_return({})
13
9
  end
14
10
 
15
11
  def params
@@ -0,0 +1,37 @@
1
+ require 'spec_helper'
2
+
3
+ describe Split::ExperimentCatalog do
4
+ subject { Split::ExperimentCatalog }
5
+
6
+ describe ".find_or_create" do
7
+ it "should not raise an error when passed strings for alternatives" do
8
+ expect { subject.find_or_create('xyz', '1', '2', '3') }.not_to raise_error
9
+ end
10
+
11
+ it "should not raise an error when passed an array for alternatives" do
12
+ expect { subject.find_or_create('xyz', ['1', '2', '3']) }.not_to raise_error
13
+ end
14
+
15
+ it "should raise the appropriate error when passed integers for alternatives" do
16
+ expect { subject.find_or_create('xyz', 1, 2, 3) }.to raise_error
17
+ end
18
+
19
+ it "should raise the appropriate error when passed symbols for alternatives" do
20
+ expect { subject.find_or_create('xyz', :a, :b, :c) }.to raise_error
21
+ end
22
+
23
+ it "should not raise error when passed an array for goals" do
24
+ expect { subject.find_or_create({'link_color' => ["purchase", "refund"]}, 'blue', 'red') }
25
+ .not_to raise_error
26
+ end
27
+
28
+ it "should not raise error when passed just one goal" do
29
+ expect { subject.find_or_create({'link_color' => "purchase"}, 'blue', 'red') }
30
+ .not_to raise_error
31
+ end
32
+
33
+ it "constructs a new experiment" do
34
+ expect(subject.find_or_create('my_exp', 'control me').control.to_s).to eq('control me')
35
+ end
36
+ end
37
+ end
@@ -48,14 +48,14 @@ describe Split::Experiment do
48
48
  expect(Time).to receive(:now).and_return(experiment_start_time)
49
49
  experiment.save
50
50
 
51
- expect(Split::Experiment.find('basket_text').start_time).to eq(experiment_start_time)
51
+ expect(Split::ExperimentCatalog.find('basket_text').start_time).to eq(experiment_start_time)
52
52
  end
53
53
 
54
54
  it "should not save the start time to redis when start_manually is enabled" do
55
55
  expect(Split.configuration).to receive(:start_manually).and_return(true)
56
56
  experiment.save
57
57
 
58
- expect(Split::Experiment.find('basket_text').start_time).to be_nil
58
+ expect(Split::ExperimentCatalog.find('basket_text').start_time).to be_nil
59
59
  end
60
60
 
61
61
  it "should save the selected algorithm to redis" do
@@ -63,7 +63,7 @@ describe Split::Experiment do
63
63
  experiment.algorithm = experiment_algorithm
64
64
  experiment.save
65
65
 
66
- expect(Split::Experiment.find('basket_text').algorithm).to eq(experiment_algorithm)
66
+ expect(Split::ExperimentCatalog.find('basket_text').algorithm).to eq(experiment_algorithm)
67
67
  end
68
68
 
69
69
  it "should handle having a start time stored as a string" do
@@ -72,7 +72,7 @@ describe Split::Experiment do
72
72
  experiment.save
73
73
  Split.redis.hset(:experiment_start_times, experiment.name, experiment_start_time)
74
74
 
75
- expect(Split::Experiment.find('basket_text').start_time).to eq(experiment_start_time)
75
+ expect(Split::ExperimentCatalog.find('basket_text').start_time).to eq(experiment_start_time)
76
76
  end
77
77
 
78
78
  it "should handle not having a start time" do
@@ -82,7 +82,7 @@ describe Split::Experiment do
82
82
 
83
83
  Split.redis.hdel(:experiment_start_times, experiment.name)
84
84
 
85
- expect(Split::Experiment.find('basket_text').start_time).to be_nil
85
+ expect(Split::ExperimentCatalog.find('basket_text').start_time).to be_nil
86
86
  end
87
87
 
88
88
  it "should not create duplicates when saving multiple times" do
@@ -106,12 +106,12 @@ describe Split::Experiment do
106
106
  describe 'find' do
107
107
  it "should return an existing experiment" do
108
108
  experiment.save
109
- experiment = Split::Experiment.find('basket_text')
109
+ experiment = Split::ExperimentCatalog.find('basket_text')
110
110
  expect(experiment.name).to eq('basket_text')
111
111
  end
112
112
 
113
113
  it "should return an existing experiment" do
114
- expect(Split::Experiment.find('non_existent_experiment')).to be_nil
114
+ expect(Split::ExperimentCatalog.find('non_existent_experiment')).to be_nil
115
115
  end
116
116
  end
117
117
 
@@ -141,7 +141,7 @@ describe Split::Experiment do
141
141
  experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :resettable => false)
142
142
  experiment.save
143
143
 
144
- e = Split::Experiment.find('basket_text')
144
+ e = Split::ExperimentCatalog.find('basket_text')
145
145
  expect(e).to eq(experiment)
146
146
  expect(e.resettable).to be_falsey
147
147
 
@@ -151,7 +151,7 @@ describe Split::Experiment do
151
151
  experiment = Split::Experiment.new('basket_text', :alternatives => ['Basket', "Cart"], :algorithm => Split::Algorithms::Whiplash)
152
152
  experiment.save
153
153
 
154
- e = Split::Experiment.find('basket_text')
154
+ e = Split::ExperimentCatalog.find('basket_text')
155
155
  expect(e).to eq(experiment)
156
156
  expect(e.algorithm).to eq(Split::Algorithms::Whiplash)
157
157
  end
@@ -160,7 +160,7 @@ describe Split::Experiment do
160
160
  experiment = Split::Experiment.new('foobar', :alternatives => ['tra', 'la'], :algorithm => Split::Algorithms::Whiplash)
161
161
  experiment.save
162
162
 
163
- e = Split::Experiment.find('foobar')
163
+ e = Split::ExperimentCatalog.find('foobar')
164
164
  expect(e).to eq(experiment)
165
165
  expect(e.alternatives.collect{|a| a.name}).to eq(['tra', 'la'])
166
166
  end
@@ -173,7 +173,7 @@ describe Split::Experiment do
173
173
 
174
174
  experiment.delete
175
175
  expect(Split.redis.exists('link_color')).to be false
176
- expect(Split::Experiment.find('link_color')).to be_nil
176
+ expect(Split::ExperimentCatalog.find('link_color')).to be_nil
177
177
  end
178
178
 
179
179
  it "should increment the version" do
@@ -255,7 +255,7 @@ describe Split::Experiment do
255
255
  end
256
256
 
257
257
  describe 'algorithm' do
258
- let(:experiment) { Split::Experiment.find_or_create('link_color', 'blue', 'red', 'green') }
258
+ let(:experiment) { Split::ExperimentCatalog.find_or_create('link_color', 'blue', 'red', 'green') }
259
259
 
260
260
  it 'should use the default algorithm if none is specified' do
261
261
  expect(experiment.algorithm).to eq(Split.configuration.algorithm)
@@ -268,7 +268,7 @@ describe Split::Experiment do
268
268
  end
269
269
 
270
270
  describe 'next_alternative' do
271
- let(:experiment) { Split::Experiment.find_or_create('link_color', 'blue', 'red', 'green') }
271
+ let(:experiment) { Split::ExperimentCatalog.find_or_create('link_color', 'blue', 'red', 'green') }
272
272
 
273
273
  it "should always return the winner if one exists" do
274
274
  green = Split::Alternative.new('green', 'link_color')
@@ -288,7 +288,7 @@ describe Split::Experiment do
288
288
  end
289
289
 
290
290
  describe 'single alternative' do
291
- let(:experiment) { Split::Experiment.find_or_create('link_color', 'blue') }
291
+ let(:experiment) { Split::ExperimentCatalog.find_or_create('link_color', 'blue') }
292
292
 
293
293
  it "should always return the color blue" do
294
294
  expect(experiment.next_alternative.name).to eq('blue')
@@ -297,7 +297,7 @@ describe Split::Experiment do
297
297
 
298
298
  describe 'changing an existing experiment' do
299
299
  def same_but_different_alternative
300
- Split::Experiment.find_or_create('link_color', 'blue', 'yellow', 'orange')
300
+ Split::ExperimentCatalog.find_or_create('link_color', 'blue', 'yellow', 'orange')
301
301
  end
302
302
 
303
303
  it "should reset an experiment if it is loaded with different alternatives" do
@@ -320,14 +320,14 @@ describe Split::Experiment do
320
320
 
321
321
  describe 'alternatives passed as non-strings' do
322
322
  it "should throw an exception if an alternative is passed that is not a string" do
323
- expect(lambda { Split::Experiment.find_or_create('link_color', :blue, :red) }).to raise_error
324
- expect(lambda { Split::Experiment.find_or_create('link_enabled', true, false) }).to raise_error
323
+ expect(lambda { Split::ExperimentCatalog.find_or_create('link_color', :blue, :red) }).to raise_error
324
+ expect(lambda { Split::ExperimentCatalog.find_or_create('link_enabled', true, false) }).to raise_error
325
325
  end
326
326
  end
327
327
 
328
328
  describe 'specifying weights' do
329
329
  let(:experiment_with_weight) {
330
- Split::Experiment.find_or_create('link_color', {'blue' => 1}, {'red' => 2 })
330
+ Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 1}, {'red' => 2 })
331
331
  }
332
332
 
333
333
  it "should work for a new experiment" do
@@ -347,18 +347,18 @@ describe Split::Experiment do
347
347
 
348
348
  context "saving experiment" do
349
349
  def same_but_different_goals
350
- Split::Experiment.find_or_create({'link_color' => ["purchase", "refund"]}, 'blue', 'red', 'green')
350
+ Split::ExperimentCatalog.find_or_create({'link_color' => ["purchase", "refund"]}, 'blue', 'red', 'green')
351
351
  end
352
352
 
353
353
  before { experiment.save }
354
354
 
355
355
  it "can find existing experiment" do
356
- expect(Split::Experiment.find("link_color").name).to eq("link_color")
356
+ expect(Split::ExperimentCatalog.find("link_color").name).to eq("link_color")
357
357
  end
358
358
 
359
359
  it "should reset an experiment if it is loaded with different goals" do
360
360
  same_experiment = same_but_different_goals
361
- expect(Split::Experiment.find("link_color").goals).to eq(["purchase", "refund"])
361
+ expect(Split::ExperimentCatalog.find("link_color").goals).to eq(["purchase", "refund"])
362
362
  end
363
363
 
364
364
  end
@@ -369,9 +369,9 @@ describe Split::Experiment do
369
369
 
370
370
  context "find or create experiment" do
371
371
  it "should have correct goals" do
372
- experiment = Split::Experiment.find_or_create({'link_color3' => ["purchase", "refund"]}, 'blue', 'red', 'green')
372
+ experiment = Split::ExperimentCatalog.find_or_create({'link_color3' => ["purchase", "refund"]}, 'blue', 'red', 'green')
373
373
  expect(experiment.goals).to eq(["purchase", "refund"])
374
- experiment = Split::Experiment.find_or_create('link_color3', 'blue', 'red', 'green')
374
+ experiment = Split::ExperimentCatalog.find_or_create('link_color3', 'blue', 'red', 'green')
375
375
  expect(experiment.goals).to eq([])
376
376
  end
377
377
  end
@@ -379,19 +379,19 @@ describe Split::Experiment do
379
379
 
380
380
  describe "beta probability calculation" do
381
381
  it "should return a hash with the probability of each alternative being the best" do
382
- experiment = Split::Experiment.find_or_create('mathematicians', 'bernoulli', 'poisson', 'lagrange')
382
+ experiment = Split::ExperimentCatalog.find_or_create('mathematicians', 'bernoulli', 'poisson', 'lagrange')
383
383
  experiment.calc_winning_alternatives
384
384
  expect(experiment.alternative_probabilities).not_to be_nil
385
385
  end
386
386
 
387
387
  it "should return between 46% and 54% probability for an experiment with 2 alternatives and no data" do
388
- experiment = Split::Experiment.find_or_create('scientists', 'einstein', 'bohr')
388
+ experiment = Split::ExperimentCatalog.find_or_create('scientists', 'einstein', 'bohr')
389
389
  experiment.calc_winning_alternatives
390
390
  expect(experiment.alternatives[0].p_winner).to be_within(0.04).of(0.50)
391
391
  end
392
392
 
393
393
  it "should calculate the probability of being the winning alternative separately for each goal" do
394
- experiment = Split::Experiment.find_or_create({'link_color3' => ["purchase", "refund"]}, 'blue', 'red', 'green')
394
+ experiment = Split::ExperimentCatalog.find_or_create({'link_color3' => ["purchase", "refund"]}, 'blue', 'red', 'green')
395
395
  goal1 = experiment.goals[0]
396
396
  goal2 = experiment.goals[1]
397
397
  experiment.alternatives.each do |alternative|