vanity 1.8.4 → 1.9.0.beta

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