vanity 1.8.4 → 1.9.0.beta

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. data/.travis.yml +3 -2
  2. data/CHANGELOG +12 -0
  3. data/Gemfile +6 -3
  4. data/Gemfile.lock +12 -10
  5. data/README.rdoc +45 -16
  6. data/Rakefile +14 -9
  7. data/doc/_layouts/page.html +4 -6
  8. data/doc/ab_testing.textile +1 -1
  9. data/doc/configuring.textile +2 -4
  10. data/doc/email.textile +1 -3
  11. data/doc/index.textile +3 -63
  12. data/doc/rails.textile +34 -8
  13. data/gemfiles/rails3.gemfile +12 -3
  14. data/gemfiles/rails3.gemfile.lock +37 -11
  15. data/gemfiles/rails31.gemfile +12 -3
  16. data/gemfiles/rails31.gemfile.lock +37 -11
  17. data/gemfiles/rails32.gemfile +12 -3
  18. data/gemfiles/rails32.gemfile.lock +37 -11
  19. data/gemfiles/rails4.gemfile +12 -3
  20. data/gemfiles/rails4.gemfile.lock +37 -11
  21. data/lib/vanity/adapters/abstract_adapter.rb +4 -0
  22. data/lib/vanity/adapters/active_record_adapter.rb +18 -10
  23. data/lib/vanity/adapters/mock_adapter.rb +8 -4
  24. data/lib/vanity/adapters/mongodb_adapter.rb +11 -7
  25. data/lib/vanity/adapters/redis_adapter.rb +88 -37
  26. data/lib/vanity/commands/report.rb +9 -9
  27. data/lib/vanity/experiment/ab_test.rb +120 -101
  28. data/lib/vanity/experiment/alternative.rb +21 -21
  29. data/lib/vanity/experiment/base.rb +5 -5
  30. data/lib/vanity/experiment/bayesian_bandit_score.rb +51 -51
  31. data/lib/vanity/experiment/definition.rb +10 -10
  32. data/lib/vanity/frameworks/rails.rb +39 -36
  33. data/lib/vanity/helpers.rb +6 -4
  34. data/lib/vanity/metric/active_record.rb +1 -1
  35. data/lib/vanity/metric/base.rb +23 -24
  36. data/lib/vanity/metric/google_analytics.rb +5 -5
  37. data/lib/vanity/playground.rb +118 -24
  38. data/lib/vanity/templates/_report.erb +20 -6
  39. data/lib/vanity/templates/vanity.css +2 -0
  40. data/lib/vanity/version.rb +1 -1
  41. data/test/adapters/redis_adapter_test.rb +106 -1
  42. data/test/dummy/config/database.yml +21 -4
  43. data/test/dummy/config/routes.rb +1 -1
  44. data/test/experiment/ab_test.rb +93 -13
  45. data/test/metric/active_record_test.rb +9 -4
  46. data/test/passenger_test.rb +43 -42
  47. data/test/playground_test.rb +50 -1
  48. data/test/rails_dashboard_test.rb +38 -1
  49. data/test/rails_helper_test.rb +5 -0
  50. data/test/rails_test.rb +66 -15
  51. data/test/test_helper.rb +24 -2
  52. data/vanity.gemspec +0 -2
  53. metadata +45 -57
@@ -5,15 +5,29 @@ module Vanity
5
5
  #
6
6
  # @since 1.4.0
7
7
  def redis_connection(spec)
8
+ require "redis"
9
+ fail "redis >= 2.1 is required" unless valid_redis_version?
8
10
  require "redis/namespace"
11
+ fail "redis-namespace >= 1.1.0 is required" unless valid_redis_namespace_version?
12
+
9
13
  RedisAdapter.new(spec)
10
14
  end
15
+
16
+ def valid_redis_version?
17
+ Gem.loaded_specs['redis'].version >= Gem::Version.create('2.1')
18
+ end
19
+
20
+ def valid_redis_namespace_version?
21
+ Gem.loaded_specs['redis'].version >= Gem::Version.create('1.1.0')
22
+ end
11
23
  end
12
24
 
13
25
  # Redis adapter.
14
26
  #
15
27
  # @since 1.4.0
16
28
  class RedisAdapter < AbstractAdapter
29
+ attr_reader :redis
30
+
17
31
  def initialize(options)
18
32
  @options = options.clone
19
33
  @options[:db] ||= @options[:database] || (@options[:path] && @options.delete(:path).split("/")[1].to_i)
@@ -43,34 +57,32 @@ module Vanity
43
57
 
44
58
  def connect!
45
59
  @redis = @options[:redis] || Redis.new(@options)
46
- @metrics = Redis::Namespace.new("vanity:metrics", :redis=>@redis)
47
- @experiments = Redis::Namespace.new("vanity:experiments", :redis=>@redis)
60
+ @metrics = Redis::Namespace.new("vanity:metrics", :redis=>redis)
61
+ @experiments = Redis::Namespace.new("vanity:experiments", :redis=>redis)
48
62
  end
49
63
 
50
64
  def to_s
51
65
  redis.id
52
66
  end
53
67
 
54
- def redis
55
- @redis
56
- end
57
-
58
68
  def flushdb
59
69
  @redis.flushdb
60
70
  end
61
71
 
62
72
  # -- Metrics --
63
-
73
+
64
74
  def get_metric_last_update_at(metric)
65
75
  last_update_at = @metrics["#{metric}:last_update_at"]
66
76
  last_update_at && Time.at(last_update_at.to_i)
67
77
  end
68
78
 
69
79
  def metric_track(metric, timestamp, identity, values)
70
- values.each_with_index do |v,i|
71
- @metrics.incrby "#{metric}:#{timestamp.to_date}:value:#{i}", v
80
+ call_redis_with_failover(metric, timestamp, identity, values) do
81
+ values.each_with_index do |v,i|
82
+ @metrics.incrby "#{metric}:#{timestamp.to_date}:value:#{i}", v
83
+ end
84
+ @metrics["#{metric}:last_update_at"] = Time.now.to_i
72
85
  end
73
- @metrics["#{metric}:last_update_at"] = Time.now.to_i
74
86
  end
75
87
 
76
88
  def metric_values(metric, from, to)
@@ -84,9 +96,15 @@ module Vanity
84
96
 
85
97
 
86
98
  # -- Experiments --
87
-
99
+
100
+ def experiment_persisted?(experiment)
101
+ !!@experiments["#{experiment}:created_at"]
102
+ end
103
+
88
104
  def set_experiment_created_at(experiment, time)
89
- @experiments.setnx "#{experiment}:created_at", time.to_i
105
+ call_redis_with_failover do
106
+ @experiments.setnx "#{experiment}:created_at", time.to_i
107
+ end
90
108
  end
91
109
 
92
110
  def get_experiment_created_at(experiment)
@@ -104,58 +122,76 @@ module Vanity
104
122
  end
105
123
 
106
124
  def is_experiment_completed?(experiment)
107
- @experiments.exists("#{experiment}:completed_at")
125
+ call_redis_with_failover do
126
+ @experiments.exists("#{experiment}:completed_at")
127
+ end
108
128
  end
109
129
 
110
130
  def ab_counts(experiment, alternative)
111
- { :participants => @experiments.scard("#{experiment}:alts:#{alternative}:participants").to_i,
112
- :converted => @experiments.scard("#{experiment}:alts:#{alternative}:converted").to_i,
113
- :conversions => @experiments["#{experiment}:alts:#{alternative}:conversions"].to_i }
131
+ {
132
+ :participants => @experiments.scard("#{experiment}:alts:#{alternative}:participants").to_i,
133
+ :converted => @experiments.scard("#{experiment}:alts:#{alternative}:converted").to_i,
134
+ :conversions => @experiments["#{experiment}:alts:#{alternative}:conversions"].to_i
135
+ }
114
136
  end
115
137
 
116
138
  def ab_show(experiment, identity, alternative)
117
- @experiments["#{experiment}:participant:#{identity}:show"] = alternative
139
+ call_redis_with_failover do
140
+ @experiments["#{experiment}:participant:#{identity}:show"] = alternative
141
+ end
118
142
  end
119
143
 
120
144
  def ab_showing(experiment, identity)
121
- alternative = @experiments["#{experiment}:participant:#{identity}:show"]
122
- alternative && alternative.to_i
145
+ call_redis_with_failover do
146
+ alternative = @experiments["#{experiment}:participant:#{identity}:show"]
147
+ alternative && alternative.to_i
148
+ end
123
149
  end
124
150
 
125
151
  def ab_not_showing(experiment, identity)
126
- @experiments.del "#{experiment}:participant:#{identity}:show"
152
+ call_redis_with_failover do
153
+ @experiments.del "#{experiment}:participant:#{identity}:show"
154
+ end
127
155
  end
128
156
 
129
157
  def ab_add_participant(experiment, alternative, identity)
130
- @experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
158
+ call_redis_with_failover(experiment, alternative, identity) do
159
+ @experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
160
+ end
131
161
  end
132
162
 
133
163
  def ab_seen(experiment, identity, alternative)
134
- if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
135
- return alternative
136
- else
137
- return nil
138
- end
164
+ call_redis_with_failover(experiment, identity, alternative) do
165
+ if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
166
+ alternative
167
+ else
168
+ nil
169
+ end
170
+ end
139
171
  end
140
172
 
141
173
  # Returns the participant's seen alternative in this experiment, if it exists
142
174
  def ab_assigned(experiment, identity)
143
- Vanity.playground.experiments[experiment].alternatives.each do |alternative|
144
- if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
145
- return alternative.id
146
- end
175
+ call_redis_with_failover do
176
+ Vanity.playground.experiments[experiment].alternatives.each do |alternative|
177
+ if @experiments.sismember "#{experiment}:alts:#{alternative.id}:participants", identity
178
+ return alternative.id
179
+ end
180
+ end
181
+ nil
147
182
  end
148
- return nil
149
183
  end
150
184
 
151
185
  def ab_add_conversion(experiment, alternative, identity, count = 1, implicit = false)
152
- if implicit
153
- @experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
154
- else
155
- participating = @experiments.sismember("#{experiment}:alts:#{alternative}:participants", identity)
186
+ call_redis_with_failover(experiment, alternative, identity, count, implicit) do
187
+ if implicit
188
+ @experiments.sadd "#{experiment}:alts:#{alternative}:participants", identity
189
+ else
190
+ participating = @experiments.sismember("#{experiment}:alts:#{alternative}:participants", identity)
191
+ end
192
+ @experiments.sadd "#{experiment}:alts:#{alternative}:converted", identity if implicit || participating
193
+ @experiments.incrby "#{experiment}:alts:#{alternative}:conversions", count
156
194
  end
157
- @experiments.sadd "#{experiment}:alts:#{alternative}:converted", identity if implicit || participating
158
- @experiments.incrby "#{experiment}:alts:#{alternative}:conversions", count
159
195
  end
160
196
 
161
197
  def ab_get_outcome(experiment)
@@ -173,6 +209,21 @@ module Vanity
173
209
  @experiments.del *alternatives unless alternatives.empty?
174
210
  end
175
211
 
212
+ protected
213
+
214
+ def call_redis_with_failover(*arguments)
215
+ calling_method = caller[0][/`.*'/][1..-2]
216
+ begin
217
+ yield
218
+ rescue => e
219
+ if Vanity.playground.failover_on_datastore_error?
220
+ Vanity.playground.on_datastore_error.call(e, self.class, calling_method, arguments)
221
+ else
222
+ raise e
223
+ end
224
+ end
225
+ end
226
+
176
227
  end
177
228
  end
178
229
  end
@@ -7,15 +7,15 @@ module Vanity
7
7
  # outside Rails).
8
8
  module Render
9
9
 
10
- # Render the named template. Used for reporting and the dashboard.
10
+ # Render the named template. Used for reporting and the dashboard.
11
11
  def render(path_or_options, locals = {})
12
12
  if path_or_options.respond_to?(:keys)
13
- render_erb(
14
- path_or_options[:file] || path_or_options[:partial],
15
- path_or_options[:locals]
16
- )
13
+ render_erb(
14
+ path_or_options[:file] || path_or_options[:partial],
15
+ path_or_options[:locals]
16
+ )
17
17
  else
18
- render_erb(path_or_options, locals)
18
+ render_erb(path_or_options, locals)
19
19
  end
20
20
  end
21
21
 
@@ -54,9 +54,9 @@ module Vanity
54
54
 
55
55
  def partialize(template_name)
56
56
  if template_name[0] != '_'
57
- "_#{template_name}"
57
+ "_#{template_name}"
58
58
  else
59
- template_name
59
+ template_name
60
60
  end
61
61
  end
62
62
  end
@@ -66,7 +66,7 @@ module Vanity
66
66
  class << self
67
67
  include Render
68
68
 
69
- # Generate an HTML report. Outputs to the named file, or stdout with no
69
+ # Generate an HTML report. Outputs to the named file, or stdout with no
70
70
  # arguments.
71
71
  def report(output = nil)
72
72
  html = render(Vanity.template("report"))
@@ -7,23 +7,23 @@ module Vanity
7
7
  # The meat.
8
8
  class AbTest < Base
9
9
  class << self
10
- # Convert z-score to probability.
11
- def probability(score)
12
- score = score.abs
13
- probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
14
- probability ? probability.last : 0
15
- end
10
+ # Convert z-score to probability.
11
+ def probability(score)
12
+ score = score.abs
13
+ probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
14
+ probability ? probability.last : 0
15
+ end
16
16
 
17
- def friendly_name
18
- "A/B Test"
19
- end
17
+ def friendly_name
18
+ "A/B Test"
19
+ end
20
20
  end
21
21
 
22
22
  DEFAULT_SCORE_METHOD = :z_score
23
23
 
24
24
  def initialize(*args)
25
25
  super
26
- @score_method = DEFAULT_SCORE_METHOD
26
+ @score_method = DEFAULT_SCORE_METHOD
27
27
  @use_probabilities = nil
28
28
  end
29
29
 
@@ -46,7 +46,7 @@ module Vanity
46
46
  # -- Alternatives --
47
47
 
48
48
  # Call this method once to set alternative values for this experiment
49
- # (requires at least two values). Call without arguments to obtain
49
+ # (requires at least two values). Call without arguments to obtain
50
50
  # current list of alternatives.
51
51
  #
52
52
  # @example Define A/B test with three alternatives
@@ -84,7 +84,7 @@ module Vanity
84
84
  alternatives.find { |alt| alt.value == value }
85
85
  end
86
86
 
87
- # What method to use for calculating score. Default is :ab_test, but can
87
+ # What method to use for calculating score. Default is :ab_test, but can
88
88
  # also be set to :bayes_bandit_score to calculate probability of each
89
89
  # alternative being the best.
90
90
  #
@@ -95,13 +95,13 @@ module Vanity
95
95
  # score_method :bayes_bandit_score
96
96
  # end
97
97
  def score_method(method=nil)
98
- if method
99
- @score_method = method
100
- end
101
- @score_method
98
+ if method
99
+ @score_method = method
100
+ end
101
+ @score_method
102
102
  end
103
103
 
104
- # Defines an A/B test with two alternatives: false and true. This is the
104
+ # Defines an A/B test with two alternatives: false and true. This is the
105
105
  # default pair of alternatives, so just syntactic sugar for those who love
106
106
  # being explicit.
107
107
  #
@@ -116,42 +116,31 @@ module Vanity
116
116
  end
117
117
  alias true_false false_true
118
118
 
119
- # Chooses a value for this experiment. You probably want to use the
119
+ # Returns fingerprint (hash) for given alternative. Can be used to lookup
120
+ # alternative for experiment without revealing what values are available
121
+ # (e.g. choosing alternative from HTTP query parameter).
122
+ def fingerprint(alternative)
123
+ Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
124
+ end
125
+
126
+ # Chooses a value for this experiment. You probably want to use the
120
127
  # Rails helper method ab_test instead.
121
128
  #
122
129
  # This method picks an alternative for the current identity and returns
123
- # the alternative's value. It will consistently choose the same
130
+ # the alternative's value. It will consistently choose the same
124
131
  # alternative for the same identity, and randomly split alternatives
125
132
  # between different identities.
126
133
  #
127
134
  # @example
128
135
  # color = experiment(:which_blue).choose
129
- def choose
136
+ def choose(request=nil)
130
137
  if @playground.collecting?
131
138
  if active?
132
139
  identity = identity()
133
140
  index = connection.ab_showing(@id, identity)
134
141
  unless index
135
- index = alternative_for(identity)
136
- if !@playground.using_js?
137
- # if we have an on_assignment block, call it on new assignments
138
- if @on_assignment_block
139
- assignment = alternatives[index.to_i]
140
- if !connection.ab_seen @id, identity, assignment
141
- @on_assignment_block.call(Vanity.context, identity, assignment, self)
142
- end
143
- end
144
- # if we are rebalancing probabilities, keep track of how long it has been since we last rebalanced
145
- if @rebalance_frequency
146
- @assignments_since_rebalancing += 1
147
- if @assignments_since_rebalancing >= @rebalance_frequency
148
- @assignments_since_rebalancing = 0
149
- rebalance!
150
- end
151
- end
152
- connection.ab_add_participant @id, index, identity
153
- check_completion!
154
- end
142
+ index = alternative_for(identity).to_i
143
+ save_assignment_if_valid_visitor(identity, index, request) unless @playground.using_js?
155
144
  end
156
145
  else
157
146
  index = connection.ab_get_outcome(@id) || alternative_for(identity)
@@ -165,22 +154,17 @@ module Vanity
165
154
  alternatives[index.to_i]
166
155
  end
167
156
 
168
- # Returns fingerprint (hash) for given alternative. Can be used to lookup
169
- # alternative for experiment without revealing what values are available
170
- # (e.g. choosing alternative from HTTP query parameter).
171
- def fingerprint(alternative)
172
- Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
173
- end
174
-
175
157
 
176
- # -- Testing --
158
+ # -- Testing and JS Callback --
177
159
 
178
- # Forces this experiment to use a particular alternative. You'll want to
179
- # use this from your test cases to test for the different alternatives.
160
+ # Forces this experiment to use a particular alternative. This may be
161
+ # used in test cases to force a specific alternative to obtain a
162
+ # deterministic test. This method also is used in the add_participant
163
+ # callback action when adding participants via vanity_js.
180
164
  #
181
165
  # @example Setup test to red button
182
166
  # setup do
183
- # experiment(:button_color).select(:red)
167
+ # experiment(:button_color).chooses(:red)
184
168
  # end
185
169
  #
186
170
  # def test_shows_red_button
@@ -189,23 +173,20 @@ module Vanity
189
173
  #
190
174
  # @example Use nil to clear selection
191
175
  # teardown do
192
- # experiment(:green_button).select(nil)
176
+ # experiment(:green_button).chooses(nil)
193
177
  # end
194
- def chooses(value)
178
+ def chooses(value, request=nil)
195
179
  if @playground.collecting?
196
180
  if value.nil?
197
181
  connection.ab_not_showing @id, identity
198
182
  else
199
183
  index = @alternatives.index(value)
200
- #add them to the experiment unless they are already in it
201
- unless index == connection.ab_showing(@id, identity)
202
- connection.ab_add_participant @id, index, identity
203
- check_completion!
204
- end
184
+ save_assignment_if_valid_visitor(identity, index, request)
185
+
205
186
  raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
206
187
  if (connection.ab_showing(@id, identity) && connection.ab_showing(@id, identity) != index) ||
207
188
  alternative_for(identity) != index
208
- connection.ab_show @id, identity, index
189
+ connection.ab_show(@id, identity, index)
209
190
  end
210
191
  end
211
192
  else
@@ -230,14 +211,14 @@ module Vanity
230
211
  # -- Reporting --
231
212
 
232
213
  def calculate_score
233
- if respond_to?(score_method)
234
- self.send(score_method)
235
- else
236
- score
237
- end
214
+ if respond_to?(score_method)
215
+ self.send(score_method)
216
+ else
217
+ score
218
+ end
238
219
  end
239
220
 
240
- # Scores alternatives based on the current tracking data. This method
221
+ # Scores alternatives based on the current tracking data. This method
241
222
  # returns a structure with the following attributes:
242
223
  # [:alts] Ordered list of alternatives, populated with scoring info.
243
224
  # [:base] Second best performing alternative.
@@ -278,7 +259,7 @@ module Vanity
278
259
  # choice alternative can only pick best if we have high probability (>90%).
279
260
  best = sorted.last if sorted.last.measure > 0.0
280
261
  choice = outcome ? alts[outcome.id] : (best && best.probability >= probability ? best : nil)
281
- Struct.new(:alts, :best, :base, :least, :choice, :method).new(alts, best, base, least, choice, :score)
262
+ Struct.new(:alts, :best, :base, :least, :choice, :method).new(alts, best, base, least, choice, :score)
282
263
  end
283
264
 
284
265
  # Scores alternatives based on the current tracking data, using Bayesian
@@ -302,24 +283,24 @@ module Vanity
302
283
  # The choice alternative is set only if its probability is higher or
303
284
  # equal to the specified probability (default is 90%).
304
285
  def bayes_bandit_score(probability = 90)
305
- begin
306
- require "backports/1.9.1/kernel/define_singleton_method" if RUBY_VERSION < "1.9"
307
- require "integration"
308
- require "rubystats"
309
- rescue LoadError
310
- fail "to use bayes_bandit_score, install integration and rubystats gems"
311
- end
286
+ begin
287
+ require "backports/1.9.1/kernel/define_singleton_method" if RUBY_VERSION < "1.9"
288
+ require "integration"
289
+ require "rubystats"
290
+ rescue LoadError
291
+ fail "to use bayes_bandit_score, install integration and rubystats gems"
292
+ end
312
293
 
313
- begin
314
- require "gsl"
315
- rescue LoadError
316
- warn "for better integration performance, install gsl gem"
317
- end
294
+ begin
295
+ require "gsl"
296
+ rescue LoadError
297
+ warn "for better integration performance, install gsl gem"
298
+ end
318
299
 
319
- BayesianBanditScore.new(alternatives, outcome).calculate!
300
+ BayesianBanditScore.new(alternatives, outcome).calculate!
320
301
  end
321
302
 
322
- # Use the result of #score or #bayes_bandit_score to derive a conclusion. Returns an
303
+ # Use the result of #score or #bayes_bandit_score to derive a conclusion. Returns an
323
304
  # array of claims.
324
305
  def conclusion(score = score)
325
306
  claims = []
@@ -338,24 +319,24 @@ module Vanity
338
319
  # we want a result that's clearly better than 2nd best.
339
320
  best, second = sorted[0], sorted[1]
340
321
  if best.measure > second.measure
341
- diff = ((best.measure - second.measure) / second.measure * 100).round
342
- better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
343
- claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
344
- if score.method == :bayes_bandit_score
345
- if best.probability >= 90
346
- claims << "With %d%% probability this result is the best." % score.best.probability
347
- else
348
- claims << "This result does not have strong confidence behind it, suggest you continue this experiment."
349
- end
350
- else
351
- if best.probability >= 90
352
- claims << "With %d%% probability this result is statistically significant." % score.best.probability
353
- else
354
- claims << "This result is not statistically significant, suggest you continue this experiment."
355
- end
356
- end
357
- sorted.delete best
358
- end
322
+ diff = ((best.measure - second.measure) / second.measure * 100).round
323
+ better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
324
+ claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
325
+ if score.method == :bayes_bandit_score
326
+ if best.probability >= 90
327
+ claims << "With %d%% probability this result is the best." % score.best.probability
328
+ else
329
+ claims << "This result does not have strong confidence behind it, suggest you continue this experiment."
330
+ end
331
+ else
332
+ if best.probability >= 90
333
+ claims << "With %d%% probability this result is statistically significant." % score.best.probability
334
+ else
335
+ claims << "This result is not statistically significant, suggest you continue this experiment."
336
+ end
337
+ end
338
+ sorted.delete best
339
+ end
359
340
  sorted.each do |alt|
360
341
  if alt.measure > 0.0
361
342
  claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.measure * 100]
@@ -413,7 +394,7 @@ module Vanity
413
394
  # Defines how the experiment can choose the optimal outcome on completion.
414
395
  #
415
396
  # By default, Vanity will take the best alternative (highest conversion
416
- # rate) and use that as the outcome. You experiment may have different
397
+ # rate) and use that as the outcome. You experiment may have different
417
398
  # needs, maybe you want the least performing alternative, or factor cost
418
399
  # in the equation?
419
400
  #
@@ -440,7 +421,7 @@ module Vanity
440
421
  return unless @playground.collecting? && active?
441
422
  super
442
423
 
443
- unless outcome
424
+ unless outcome
444
425
  if @outcome_is
445
426
  begin
446
427
  result = @outcome_is.call
@@ -530,6 +511,44 @@ module Vanity
530
511
  return Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % @alternatives.size
531
512
  end
532
513
 
514
+ # Saves the assignment of an alternative to a person and performs the
515
+ # necessary housekeeping. Ignores repeat identities and filters using
516
+ # Playground#request_filter.
517
+ def save_assignment_if_valid_visitor(identity, index, request)
518
+ return if index == connection.ab_showing(@id, identity) || filter_visitor?(request)
519
+
520
+ call_on_assignment_if_available(identity, index)
521
+ rebalance_if_necessary!
522
+
523
+ connection.ab_add_participant(@id, index, identity)
524
+ check_completion!
525
+ end
526
+
527
+ def filter_visitor?(request)
528
+ @playground.request_filter.call(request)
529
+ end
530
+
531
+ def call_on_assignment_if_available(identity, index)
532
+ # if we have an on_assignment block, call it on new assignments
533
+ if @on_assignment_block
534
+ assignment = alternatives[index]
535
+ if !connection.ab_seen @id, identity, assignment
536
+ @on_assignment_block.call(Vanity.context, identity, assignment, self)
537
+ end
538
+ end
539
+ end
540
+
541
+ def rebalance_if_necessary!
542
+ # if we are rebalancing probabilities, keep track of how long it has been since we last rebalanced
543
+ if @rebalance_frequency
544
+ @assignments_since_rebalancing += 1
545
+ if @assignments_since_rebalancing >= @rebalance_frequency
546
+ @assignments_since_rebalancing = 0
547
+ rebalance!
548
+ end
549
+ end
550
+ end
551
+
533
552
  begin
534
553
  a = 50.0
535
554
  # Returns array of [z-score, percentage]
@@ -543,7 +562,7 @@ module Vanity
543
562
 
544
563
 
545
564
  module Definition
546
- # Define an A/B test with the given name. For example:
565
+ # Define an A/B test with the given name. For example:
547
566
  # ab_test "New Banner" do
548
567
  # alternatives :red, :green, :blue
549
568
  # end