split 0.4.6 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. data/.travis.yml +11 -3
  2. data/CHANGELOG.mdown +22 -1
  3. data/CONTRIBUTING.md +10 -0
  4. data/LICENSE +1 -1
  5. data/README.mdown +235 -60
  6. data/lib/split.rb +8 -9
  7. data/lib/split/algorithms.rb +3 -0
  8. data/lib/split/algorithms/weighted_sample.rb +17 -0
  9. data/lib/split/algorithms/whiplash.rb +35 -0
  10. data/lib/split/alternative.rb +12 -4
  11. data/lib/split/configuration.rb +91 -1
  12. data/lib/split/dashboard/helpers.rb +3 -3
  13. data/lib/split/dashboard/views/_experiment.erb +1 -1
  14. data/lib/split/exceptions.rb +4 -0
  15. data/lib/split/experiment.rb +112 -24
  16. data/lib/split/extensions.rb +3 -0
  17. data/lib/split/extensions/array.rb +4 -0
  18. data/lib/split/extensions/string.rb +15 -0
  19. data/lib/split/helper.rb +87 -55
  20. data/lib/split/metric.rb +68 -0
  21. data/lib/split/persistence.rb +28 -0
  22. data/lib/split/persistence/cookie_adapter.rb +44 -0
  23. data/lib/split/persistence/session_adapter.rb +28 -0
  24. data/lib/split/trial.rb +43 -0
  25. data/lib/split/version.rb +3 -3
  26. data/spec/algorithms/weighted_sample_spec.rb +18 -0
  27. data/spec/algorithms/whiplash_spec.rb +23 -0
  28. data/spec/alternative_spec.rb +81 -9
  29. data/spec/configuration_spec.rb +61 -9
  30. data/spec/dashboard_helpers_spec.rb +2 -5
  31. data/spec/dashboard_spec.rb +0 -2
  32. data/spec/experiment_spec.rb +144 -74
  33. data/spec/helper_spec.rb +234 -29
  34. data/spec/metric_spec.rb +30 -0
  35. data/spec/persistence/cookie_adapter_spec.rb +31 -0
  36. data/spec/persistence/session_adapter_spec.rb +31 -0
  37. data/spec/persistence_spec.rb +33 -0
  38. data/spec/spec_helper.rb +12 -0
  39. data/spec/support/cookies_mock.rb +19 -0
  40. data/spec/trial_spec.rb +59 -0
  41. data/split.gemspec +7 -3
  42. metadata +58 -29
  43. data/Guardfile +0 -5
@@ -1,8 +1,7 @@
1
- require 'split/experiment'
2
- require 'split/alternative'
3
- require 'split/helper'
4
- require 'split/version'
5
- require 'split/configuration'
1
+ %w[algorithms extensions metric trial experiment alternative helper version configuration persistence exceptions].each do |f|
2
+ require "split/#{f}"
3
+ end
4
+
6
5
  require 'split/engine' if defined?(Rails)
7
6
  require 'redis/namespace'
8
7
 
@@ -52,9 +51,9 @@ module Split
52
51
  # config.ignore_ips = '192.168.2.1'
53
52
  # end
54
53
  def configure
55
- self.configuration ||= Configuration.new
56
- yield(configuration)
57
- end
54
+ self.configuration ||= Configuration.new
55
+ yield(configuration)
56
+ end
58
57
  end
59
58
 
60
- Split.configure {}
59
+ Split.configure {}
@@ -0,0 +1,3 @@
1
+ %w[weighted_sample whiplash].each do |f|
2
+ require "split/algorithms/#{f}"
3
+ end
@@ -0,0 +1,17 @@
1
+ module Split
2
+ module Algorithms
3
+ module WeightedSample
4
+ def self.choose_alternative(experiment)
5
+ weights = experiment.alternatives.map(&:weight)
6
+
7
+ total = weights.inject(:+)
8
+ point = rand * total
9
+
10
+ experiment.alternatives.zip(weights).each do |n,w|
11
+ return n if w >= point
12
+ point -= w
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,35 @@
1
+ # A multi-armed bandit implementation inspired by
2
+ # @aaronsw and victorykit/whiplash
3
+ require 'simple-random'
4
+
5
+ module Split
6
+ module Algorithms
7
+ module Whiplash
8
+ def self.choose_alternative(experiment)
9
+ experiment[best_guess(experiment.alternatives)]
10
+ end
11
+
12
+ private
13
+
14
+ def self.arm_guess(participants, completions)
15
+ a = [participants, 0].max
16
+ b = [participants-completions, 0].max
17
+ s = SimpleRandom.new; s.set_seed; s.beta(a+fairness_constant, b+fairness_constant)
18
+ end
19
+
20
+ def self.best_guess(alternatives)
21
+ guesses = {}
22
+ alternatives.each do |alternative|
23
+ guesses[alternative.name] = arm_guess(alternative.participant_count, alternative.completed_count)
24
+ end
25
+ gmax = guesses.values.max
26
+ best = guesses.keys.select {|name| guesses[name] == gmax }
27
+ return best.sample
28
+ end
29
+
30
+ def self.fairness_constant
31
+ 7
32
+ end
33
+ end
34
+ end
35
+ end
@@ -20,27 +20,33 @@ module Split
20
20
  end
21
21
 
22
22
  def participant_count
23
- Split.redis.hget(key, 'participant_count').to_i
23
+ @participant_count ||= Split.redis.hget(key, 'participant_count').to_i
24
24
  end
25
25
 
26
26
  def participant_count=(count)
27
+ @participant_count = count
27
28
  Split.redis.hset(key, 'participant_count', count.to_i)
28
29
  end
29
30
 
30
31
  def completed_count
31
- Split.redis.hget(key, 'completed_count').to_i
32
+ @completed_count ||= Split.redis.hget(key, 'completed_count').to_i
33
+ end
34
+
35
+ def unfinished_count
36
+ participant_count - completed_count
32
37
  end
33
38
 
34
39
  def completed_count=(count)
40
+ @completed_count = count
35
41
  Split.redis.hset(key, 'completed_count', count.to_i)
36
42
  end
37
43
 
38
44
  def increment_participation
39
- Split.redis.hincrby key, 'participant_count', 1
45
+ @participant_count = Split.redis.hincrby key, 'participant_count', 1
40
46
  end
41
47
 
42
48
  def increment_completion
43
- Split.redis.hincrby key, 'completed_count', 1
49
+ @completed_count = Split.redis.hincrby key, 'completed_count', 1
44
50
  end
45
51
 
46
52
  def control?
@@ -87,6 +93,8 @@ module Split
87
93
  end
88
94
 
89
95
  def reset
96
+ @participant_count = nil
97
+ @completed_count = nil
90
98
  Split.redis.hmset key, 'participant_count', 0, 'completed_count', 0
91
99
  end
92
100
 
@@ -1,5 +1,18 @@
1
1
  module Split
2
2
  class Configuration
3
+ BOTS = {
4
+ 'Baidu' => 'Chinese spider',
5
+ 'Gigabot' => 'Gigabot spider',
6
+ 'Googlebot' => 'Google spider',
7
+ 'libwww-perl' => 'Perl client-server library loved by script kids',
8
+ 'lwp-trivial' => 'Another Perl library loved by script kids',
9
+ 'msnbot' => 'Microsoft bot',
10
+ 'SiteUptime' => 'Site monitoring services',
11
+ 'Slurp' => 'Yahoo spider',
12
+ 'WordPress' => 'WordPress spider',
13
+ 'ZIBB' => 'ZIBB spider',
14
+ 'ZyBorg' => 'Zyborg? Hmmm....'
15
+ }
3
16
  attr_accessor :robot_regex
4
17
  attr_accessor :ignore_ip_addresses
5
18
  attr_accessor :db_failover
@@ -7,15 +20,92 @@ module Split
7
20
  attr_accessor :db_failover_allow_parameter_override
8
21
  attr_accessor :allow_multiple_experiments
9
22
  attr_accessor :enabled
23
+ attr_accessor :experiments
24
+ attr_accessor :persistence
25
+ attr_accessor :algorithm
26
+
27
+ def disabled?
28
+ !enabled
29
+ end
30
+
31
+ def experiment_for(name)
32
+ if normalized_experiments
33
+ normalized_experiments[name]
34
+ end
35
+ end
36
+
37
+ def metrics
38
+ return @metrics if defined?(@metrics)
39
+ @metrics = {}
40
+ if self.experiments
41
+ self.experiments.each do |key, value|
42
+ metric_name = value[:metric]
43
+ if metric_name
44
+ @metrics[metric_name] ||= []
45
+ @metrics[metric_name] << Split::Experiment.load_from_configuration(key)
46
+ end
47
+ end
48
+ end
49
+ @metrics
50
+ end
51
+
52
+ def normalized_experiments
53
+ if @experiments.nil?
54
+ nil
55
+ else
56
+ experiment_config = {}
57
+ @experiments.keys.each do | name |
58
+ experiment_config[name] = {}
59
+ end
60
+ @experiments.each do | experiment_name, settings|
61
+ experiment_config[experiment_name][:alternatives] = normalize_alternatives(settings[:alternatives]) if settings[:alternatives]
62
+ end
63
+ experiment_config
64
+ end
65
+ end
66
+
67
+
68
+ def normalize_alternatives(alternatives)
69
+ given_probability, num_with_probability = alternatives.inject([0,0]) do |a,v|
70
+ p, n = a
71
+ if v.kind_of?(Hash) && v[:percent]
72
+ [p + v[:percent], n + 1]
73
+ else
74
+ a
75
+ end
76
+ end
77
+
78
+ num_without_probability = alternatives.length - num_with_probability
79
+ unassigned_probability = ((100.0 - given_probability) / num_without_probability / 100.0)
80
+
81
+ if num_with_probability.nonzero?
82
+ alternatives = alternatives.map do |v|
83
+ if v.kind_of?(Hash) && v[:name] && v[:percent]
84
+ { v[:name] => v[:percent] / 100.0 }
85
+ elsif v.kind_of?(Hash) && v[:name]
86
+ { v[:name] => unassigned_probability }
87
+ else
88
+ { v => unassigned_probability }
89
+ end
90
+ end
91
+ [alternatives.shift, alternatives]
92
+ else
93
+ alternatives = alternatives.dup
94
+ [alternatives.shift, alternatives]
95
+ end
96
+ end
10
97
 
11
98
  def initialize
12
- @robot_regex = /\b(Baidu|Gigabot|Googlebot|libwww-perl|lwp-trivial|msnbot|SiteUptime|Slurp|WordPress|ZIBB|ZyBorg)\b/i
99
+ @robot_regex = /\b(#{BOTS.keys.join('|')})\b/i
13
100
  @ignore_ip_addresses = []
14
101
  @db_failover = false
15
102
  @db_failover_on_db_error = proc{|error|} # e.g. use Rails logger here
16
103
  @db_failover_allow_parameter_override = false
17
104
  @allow_multiple_experiments = false
18
105
  @enabled = true
106
+ @experiments = {}
107
+ @persistence = Split::Persistence::SessionAdapter
108
+ @algorithm = Split::Algorithms::WeightedSample
19
109
  end
20
110
  end
21
111
  end
@@ -23,11 +23,11 @@ module Split
23
23
 
24
24
  if z == 0.0
25
25
  'No Change'
26
- elsif z < 1.96
26
+ elsif z < 1.645
27
27
  'no confidence'
28
- elsif z < 2.57
28
+ elsif z < 1.96
29
29
  '95% confidence'
30
- elsif z < 3.29
30
+ elsif z < 2.57
31
31
  '99% confidence'
32
32
  else
33
33
  '99.9% confidence'
@@ -37,7 +37,7 @@
37
37
  <% end %>
38
38
  </td>
39
39
  <td><%= alternative.participant_count %></td>
40
- <td><%= alternative.participant_count - alternative.completed_count %></td>
40
+ <td><%= alternative.unfinished_count %></td>
41
41
  <td><%= alternative.completed_count %></td>
42
42
  <td>
43
43
  <%= number_to_percentage(alternative.conversion_rate) %>%
@@ -0,0 +1,4 @@
1
+ module Split
2
+ class InvalidPersistenceAdapterError < StandardError; end
3
+ class ExperimentNotFound < StandardError; end
4
+ end
@@ -1,12 +1,40 @@
1
1
  module Split
2
2
  class Experiment
3
3
  attr_accessor :name
4
+ attr_writer :algorithm
5
+ attr_accessor :resettable
4
6
 
5
- def initialize(name, *alternative_names)
6
- @name = name.to_s
7
- @alternatives = alternative_names.map do |alternative|
8
- Split::Alternative.new(alternative, name)
9
- end
7
+ def initialize(name, options = {})
8
+ options = {
9
+ :resettable => true,
10
+ }.merge(options)
11
+
12
+ @name = name.to_s
13
+ @alternatives = options[:alternatives] if !options[:alternatives].nil?
14
+
15
+ if !options[:algorithm].nil?
16
+ @algorithm = options[:algorithm].is_a?(String) ? options[:algorithm].constantize : options[:algorithm]
17
+ end
18
+
19
+ if !options[:resettable].nil?
20
+ @resettable = options[:resettable].is_a?(String) ? options[:resettable] == 'true' : options[:resettable]
21
+ end
22
+
23
+ if !options[:alternative_names].nil?
24
+ @alternatives = options[:alternative_names].map do |alternative|
25
+ Split::Alternative.new(alternative, name)
26
+ end
27
+ end
28
+
29
+
30
+ end
31
+
32
+ def algorithm
33
+ @algorithm ||= Split.configuration.algorithm
34
+ end
35
+
36
+ def ==(obj)
37
+ self.name == obj.name
10
38
  end
11
39
 
12
40
  def winner
@@ -16,6 +44,10 @@ module Split
16
44
  nil
17
45
  end
18
46
  end
47
+
48
+ def participant_count
49
+ alternatives.inject(0){|sum,a| sum + a.participant_count}
50
+ end
19
51
 
20
52
  def control
21
53
  alternatives.first
@@ -33,6 +65,10 @@ module Split
33
65
  t = Split.redis.hget(:experiment_start_times, @name)
34
66
  Time.parse(t) if t
35
67
  end
68
+
69
+ def [](name)
70
+ alternatives.find{|a| a.name == name}
71
+ end
36
72
 
37
73
  def alternatives
38
74
  @alternatives.dup
@@ -47,14 +83,10 @@ module Split
47
83
  end
48
84
 
49
85
  def random_alternative
50
- weights = alternatives.map(&:weight)
51
-
52
- total = weights.inject(:+)
53
- point = rand * total
54
-
55
- alternatives.zip(weights).each do |n,w|
56
- return n if w >= point
57
- point -= w
86
+ if alternatives.length > 1
87
+ Split.configuration.algorithm.choose_alternative(self)
88
+ else
89
+ alternatives.first
58
90
  end
59
91
  end
60
92
 
@@ -78,6 +110,10 @@ module Split
78
110
  "#{key}:finished"
79
111
  end
80
112
 
113
+ def resettable?
114
+ resettable
115
+ end
116
+
81
117
  def reset
82
118
  alternatives.each(&:reset)
83
119
  reset_winner
@@ -105,9 +141,31 @@ module Split
105
141
  Split.redis.del(name)
106
142
  @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name) }
107
143
  end
144
+ config_key = Split::Experiment.experiment_config_key(name)
145
+ Split.redis.hset(config_key, :resettable, resettable)
146
+ Split.redis.hset(config_key, :algorithm, algorithm.to_s)
147
+ self
108
148
  end
109
149
 
110
150
  def self.load_alternatives_for(name)
151
+ if Split.configuration.experiment_for(name)
152
+ load_alternatives_from_configuration_for(name)
153
+ else
154
+ load_alternatives_from_redis_for(name)
155
+ end
156
+ end
157
+
158
+ def self.load_alternatives_from_configuration_for(name)
159
+ alts = Split.configuration.experiment_for(name)[:alternatives]
160
+ raise ArgumentError, "Experiment configuration is missing :alternatives array" if alts.nil?
161
+ if alts.is_a?(Hash)
162
+ alts.keys
163
+ else
164
+ alts.flatten
165
+ end
166
+ end
167
+
168
+ def self.load_alternatives_from_redis_for(name)
111
169
  case Split.redis.type(name)
112
170
  when 'set' # convert legacy sets to lists
113
171
  alts = Split.redis.smembers(name)
@@ -119,14 +177,46 @@ module Split
119
177
  end
120
178
  end
121
179
 
180
+ def self.load_from_configuration(name)
181
+ exp_config = Split.configuration.experiment_for(name) || {}
182
+ self.new(name, :alternative_names => load_alternatives_for(name),
183
+ :resettable => exp_config[:resettable],
184
+ :algorithm => exp_config[:algorithm])
185
+ end
186
+
187
+ def self.load_from_redis(name)
188
+ exp_config = Split.redis.hgetall(experiment_config_key(name))
189
+ self.new(name, :alternative_names => load_alternatives_for(name),
190
+ :resettable => exp_config['resettable'],
191
+ :algorithm => exp_config['algorithm'])
192
+ end
193
+
194
+ def self.experiment_config_key(name)
195
+ "experiment_configurations/#{name}"
196
+ end
197
+
122
198
  def self.all
123
- Array(Split.redis.smembers(:experiments)).map {|e| find(e)}
199
+ Array(all_experiment_names_from_redis + all_experiment_names_from_configuration).map {|e| find(e)}
124
200
  end
125
201
 
202
+ def self.all_experiment_names_from_redis
203
+ Split.redis.smembers(:experiments)
204
+ end
205
+
206
+ def self.all_experiment_names_from_configuration
207
+ Split.configuration.experiments ? Split.configuration.experiments.keys : []
208
+ end
209
+
210
+
126
211
  def self.find(name)
127
- if Split.redis.exists(name)
128
- self.new(name, *load_alternatives_for(name))
212
+ if Split.configuration.experiment_for(name)
213
+ obj = load_from_configuration(name)
214
+ elsif Split.redis.exists(name)
215
+ obj = load_from_redis(name)
216
+ else
217
+ obj = nil
129
218
  end
219
+ obj
130
220
  end
131
221
 
132
222
  def self.find_or_create(key, *alternatives)
@@ -135,8 +225,6 @@ module Split
135
225
  if alternatives.length == 1
136
226
  if alternatives[0].is_a? Hash
137
227
  alternatives = alternatives[0].map{|k,v| {k => v} }
138
- else
139
- raise InvalidArgument, 'You must declare at least 2 alternatives'
140
228
  end
141
229
  end
142
230
 
@@ -145,16 +233,16 @@ module Split
145
233
  if Split.redis.exists(name)
146
234
  existing_alternatives = load_alternatives_for(name)
147
235
  if existing_alternatives == alts.map(&:name)
148
- experiment = self.new(name, *alternatives)
236
+ experiment = self.new(name, :alternative_names => alternatives)
149
237
  else
150
- exp = self.new(name, *existing_alternatives)
238
+ exp = self.new(name, :alternative_names => existing_alternatives)
151
239
  exp.reset
152
240
  exp.alternatives.each(&:delete)
153
- experiment = self.new(name, *alternatives)
241
+ experiment = self.new(name, :alternative_names =>alternatives)
154
242
  experiment.save
155
243
  end
156
244
  else
157
- experiment = self.new(name, *alternatives)
245
+ experiment = self.new(name, :alternative_names => alternatives)
158
246
  experiment.save
159
247
  end
160
248
  return experiment
@@ -164,7 +252,7 @@ module Split
164
252
  def self.initialize_alternatives(alternatives, name)
165
253
 
166
254
  unless alternatives.all? { |a| Split::Alternative.valid?(a) }
167
- raise InvalidArgument, 'Alternatives must be strings'
255
+ raise ArgumentError, 'Alternatives must be strings'
168
256
  end
169
257
 
170
258
  alternatives.map do |alternative|
@@ -172,4 +260,4 @@ module Split
172
260
  end
173
261
  end
174
262
  end
175
- end
263
+ end