split 1.0.0 → 1.1.0

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.
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|