split 0.4.6 → 0.5.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.
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