tupalo-vanity 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. data/CHANGELOG +243 -0
  2. data/Gemfile +24 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.rdoc +74 -0
  5. data/Rakefile +189 -0
  6. data/bin/vanity +69 -0
  7. data/lib/vanity.rb +36 -0
  8. data/lib/vanity/adapters/abstract_adapter.rb +135 -0
  9. data/lib/vanity/adapters/active_record_adapter.rb +304 -0
  10. data/lib/vanity/adapters/mock_adapter.rb +157 -0
  11. data/lib/vanity/adapters/mongodb_adapter.rb +162 -0
  12. data/lib/vanity/adapters/redis_adapter.rb +154 -0
  13. data/lib/vanity/backport.rb +26 -0
  14. data/lib/vanity/commands/list.rb +21 -0
  15. data/lib/vanity/commands/report.rb +64 -0
  16. data/lib/vanity/commands/upgrade.rb +34 -0
  17. data/lib/vanity/experiment/ab_test.rb +482 -0
  18. data/lib/vanity/experiment/base.rb +212 -0
  19. data/lib/vanity/frameworks/rails.rb +244 -0
  20. data/lib/vanity/helpers.rb +59 -0
  21. data/lib/vanity/metric/active_record.rb +83 -0
  22. data/lib/vanity/metric/base.rb +244 -0
  23. data/lib/vanity/metric/google_analytics.rb +83 -0
  24. data/lib/vanity/metric/remote.rb +53 -0
  25. data/lib/vanity/playground.rb +332 -0
  26. data/lib/vanity/templates/_ab_test.erb +28 -0
  27. data/lib/vanity/templates/_experiment.erb +5 -0
  28. data/lib/vanity/templates/_experiments.erb +7 -0
  29. data/lib/vanity/templates/_metric.erb +14 -0
  30. data/lib/vanity/templates/_metrics.erb +13 -0
  31. data/lib/vanity/templates/_report.erb +27 -0
  32. data/lib/vanity/templates/flot.min.js +1 -0
  33. data/lib/vanity/templates/jquery.min.js +19 -0
  34. data/lib/vanity/templates/vanity.css +26 -0
  35. data/lib/vanity/templates/vanity.js +82 -0
  36. data/lib/vanity/version.rb +11 -0
  37. data/test/experiment/ab_test.rb +700 -0
  38. data/test/experiment/base_test.rb +136 -0
  39. data/test/experiments/age_and_zipcode.rb +19 -0
  40. data/test/experiments/metrics/cheers.rb +3 -0
  41. data/test/experiments/metrics/signups.rb +2 -0
  42. data/test/experiments/metrics/yawns.rb +3 -0
  43. data/test/experiments/null_abc.rb +5 -0
  44. data/test/metric/active_record_test.rb +249 -0
  45. data/test/metric/base_test.rb +293 -0
  46. data/test/metric/google_analytics_test.rb +104 -0
  47. data/test/metric/remote_test.rb +108 -0
  48. data/test/myapp/app/controllers/application_controller.rb +2 -0
  49. data/test/myapp/app/controllers/main_controller.rb +7 -0
  50. data/test/myapp/config/boot.rb +110 -0
  51. data/test/myapp/config/environment.rb +10 -0
  52. data/test/myapp/config/environments/production.rb +0 -0
  53. data/test/myapp/config/routes.rb +3 -0
  54. data/test/passenger_test.rb +43 -0
  55. data/test/playground_test.rb +10 -0
  56. data/test/rails_test.rb +294 -0
  57. data/test/test_helper.rb +134 -0
  58. data/tupalo-vanity.gemspec +25 -0
  59. metadata +152 -0
@@ -0,0 +1,26 @@
1
+ class Time
2
+ unless method_defined?(:to_date)
3
+ # Backported from Ruby 1.9.
4
+ def to_date
5
+ jd = Date.__send__(:civil_to_jd, year, mon, mday, Date::ITALY)
6
+ Date.new!(Date.__send__(:jd_to_ajd, jd, 0, 0), 0, Date::ITALY)
7
+ end
8
+ end
9
+ end
10
+
11
+ class Date
12
+ unless method_defined?(:to_date)
13
+ # Backported from Ruby 1.9.
14
+ def to_date
15
+ self
16
+ end
17
+ end
18
+
19
+ unless method_defined?(:to_time)
20
+ # Backported from Ruby 1.9.
21
+ def to_time
22
+ Time.local(year, mon, mday)
23
+ end
24
+ end
25
+ end
26
+
@@ -0,0 +1,21 @@
1
+ module Vanity
2
+ module Commands
3
+ class << self
4
+ # Lists all experiments and metrics.
5
+ def list
6
+ Vanity.playground.experiments.each do |id, experiment|
7
+ puts "experiment :%-.20s (%-.40s)" % [id, experiment.name]
8
+ if experiment.respond_to?(:alternatives)
9
+ experiment.alternatives.each do |alt|
10
+ hash = experiment.fingerprint(alt)
11
+ puts " %s: %-40.40s (%s)" % [alt.name, alt.value, hash]
12
+ end
13
+ end
14
+ end
15
+ Vanity.playground.metrics.each do |id, metric|
16
+ puts "metric :%-.20s (%-.40s)" % [id, metric.name]
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,64 @@
1
+ require "erb"
2
+ require "cgi"
3
+
4
+ module Vanity
5
+
6
+ # Render method available to templates (when used by Vanity command line,
7
+ # outside Rails).
8
+ module Render
9
+
10
+ # Render the named template. Used for reporting and the dashboard.
11
+ def render(path, locals = {})
12
+ locals[:playground] = self
13
+ keys = locals.keys
14
+ struct = Struct.new(*keys)
15
+ struct.send :include, Render
16
+ locals = struct.new(*locals.values_at(*keys))
17
+ dir, base = File.split(path)
18
+ path = File.join(dir, "_#{base}")
19
+ erb = ERB.new(File.read(path), nil, '<>')
20
+ erb.filename = path
21
+ erb.result(locals.instance_eval { binding })
22
+ end
23
+
24
+ # Escape HTML.
25
+ def vanity_h(html)
26
+ CGI.escapeHTML(html.to_s)
27
+ end
28
+
29
+ def vanity_html_safe(text)
30
+ text
31
+ end
32
+
33
+ # Dumbed down from Rails' simple_format.
34
+ def vanity_simple_format(text, options={})
35
+ open = "<p #{options.map { |k,v| "#{k}=\"#{CGI.escapeHTML v}\"" }.join(" ")}>"
36
+ text = open + text.gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
37
+ gsub(/\n\n+/, "</p>\n\n#{open}"). # 2+ newline -> paragraph
38
+ gsub(/([^\n]\n)(?=[^\n])/, '\1<br />') + # 1 newline -> br
39
+ "</p>"
40
+ end
41
+ end
42
+
43
+ # Commands available when running Vanity from the command line (see bin/vanity).
44
+ module Commands
45
+ class << self
46
+ include Render
47
+
48
+ # Generate an HTML report. Outputs to the named file, or stdout with no
49
+ # arguments.
50
+ def report(output = nil)
51
+ html = render(Vanity.template("report"))
52
+ if output
53
+ File.open output, 'w' do |file|
54
+ file.write html
55
+ end
56
+ puts "New report available in #{output}"
57
+ else
58
+ $stdout.write html
59
+ end
60
+ end
61
+
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,34 @@
1
+ module Vanity
2
+ module Commands
3
+ class << self
4
+ # Upgrade to newer version of Vanity (this usually means doing magic in
5
+ # the database)
6
+ def upgrade
7
+ if Vanity.playground.connection.respond_to?(:redis)
8
+ redis = Vanity.playground.connection.redis
9
+ # Upgrade metrics from 1.3 to 1.4
10
+ keys = redis.keys("metrics:*")
11
+ if keys.empty?
12
+ puts "No metrics to upgrade"
13
+ else
14
+ puts "Updating #{keys.map { |name| name.split(":")[1] }.uniq.length} metrics"
15
+ keys.each do |key|
16
+ key << ":value:0" if key[/\d{4}-\d{2}-\d{2}$/]
17
+ redis.renamenx key, "vanity:#{key}"
18
+ end
19
+ end
20
+ # Upgrade experiments from 1.3 to 1.4
21
+ keys = redis.keys("vanity:1:*")
22
+ if keys.empty?
23
+ puts "No experiments to upgrade"
24
+ else
25
+ puts "Updating #{keys.map { |name| name.split(":")[2] }.uniq.length} experiments"
26
+ keys.each do |key|
27
+ redis.renamenx key, key.gsub(":1:", ":experiments:")
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,482 @@
1
+ require "digest/md5"
2
+
3
+ module Vanity
4
+ module Experiment
5
+
6
+ # One of several alternatives in an A/B test (see AbTest#alternatives).
7
+ class Alternative
8
+
9
+ def initialize(experiment, id, value, participants, converted, conversions)
10
+ @experiment = experiment
11
+ @id = id
12
+ @name = "option #{(@id + 65).chr}"
13
+ @value = value
14
+ @participants, @converted, @conversions = participants, converted, conversions
15
+ end
16
+
17
+ # Alternative id, only unique for this experiment.
18
+ attr_reader :id
19
+
20
+ # Alternative name (option A, option B, etc).
21
+ attr_reader :name
22
+
23
+ # Alternative value.
24
+ attr_reader :value
25
+
26
+ # Experiment this alternative belongs to.
27
+ attr_reader :experiment
28
+
29
+ # Number of participants who viewed this alternative.
30
+ attr_reader :participants
31
+
32
+ # Number of participants who converted on this alternative (a participant is counted only once).
33
+ attr_reader :converted
34
+
35
+ # Number of conversions for this alternative (same participant may be counted more than once).
36
+ attr_reader :conversions
37
+
38
+ # Z-score for this alternative, related to 2nd-best performing alternative. Populated by AbTest#score.
39
+ attr_accessor :z_score
40
+
41
+ # Probability derived from z-score. Populated by AbTest#score.
42
+ attr_accessor :probability
43
+
44
+ # Difference from least performing alternative. Populated by AbTest#score.
45
+ attr_accessor :difference
46
+
47
+ # Conversion rate calculated as converted/participants, rounded to 3 places.
48
+ def conversion_rate
49
+ @conversion_rate ||= (participants > 0 ? (converted.to_f/participants.to_f * 1000).round / 1000.0 : 0.0)
50
+ end
51
+
52
+ # The measure we use to order (sort) alternatives and decide which one is better (by calculating z-score).
53
+ # Defaults to conversion rate.
54
+ def measure
55
+ conversion_rate
56
+ end
57
+
58
+ def <=>(other)
59
+ measure <=> other.measure
60
+ end
61
+
62
+ def ==(other)
63
+ other && id == other.id && experiment == other.experiment
64
+ end
65
+
66
+ def to_s
67
+ name
68
+ end
69
+
70
+ def inspect
71
+ "#{name}: #{value} #{converted}/#{participants}"
72
+ end
73
+
74
+ end
75
+
76
+
77
+ # The meat.
78
+ class AbTest < Base
79
+ class << self
80
+
81
+ # Convert z-score to probability.
82
+ def probability(score)
83
+ score = score.abs
84
+ probability = AbTest::Z_TO_PROBABILITY.find { |z,p| score >= z }
85
+ probability ? probability.last : 0
86
+ end
87
+
88
+ def friendly_name
89
+ "A/B Test"
90
+ end
91
+
92
+ end
93
+
94
+ def initialize(*args)
95
+ super
96
+ end
97
+
98
+
99
+ # -- Metric --
100
+
101
+ # Tells A/B test which metric we're measuring, or returns metric in use.
102
+ #
103
+ # @example Define A/B test against coolness metric
104
+ # ab_test "Background color" do
105
+ # metrics :coolness
106
+ # alternatives "red", "blue", "orange"
107
+ # end
108
+ # @example Find metric for A/B test
109
+ # puts "Measures: " + experiment(:background_color).metrics.map(&:name)
110
+ def metrics(*args)
111
+ @metrics = args.map { |id| @playground.metric(id) } unless args.empty?
112
+ @metrics
113
+ end
114
+
115
+
116
+ # -- Alternatives --
117
+
118
+ # Call this method once to set alternative values for this experiment
119
+ # (requires at least two values). Call without arguments to obtain
120
+ # current list of alternatives.
121
+ #
122
+ # @example Define A/B test with three alternatives
123
+ # ab_test "Background color" do
124
+ # metrics :coolness
125
+ # alternatives "red", "blue", "orange"
126
+ # end
127
+ #
128
+ # @example Find out which alternatives this test uses
129
+ # alts = experiment(:background_color).alternatives
130
+ # puts "#{alts.count} alternatives, with the colors: #{alts.map(&:value).join(", ")}"
131
+ def alternatives(*args)
132
+ @alternatives = args.empty? ? [true, false] : args.clone
133
+ class << self
134
+ define_method :alternatives, instance_method(:_alternatives)
135
+ end
136
+ nil
137
+ end
138
+
139
+ def _alternatives
140
+ alts = []
141
+ @alternatives.each_with_index do |value, i|
142
+ counts = @playground.collecting? ? connection.ab_counts(@id, i) : Hash.new(0)
143
+ alts << Alternative.new(self, i, value, counts[:participants], counts[:converted], counts[:conversions])
144
+ end
145
+ alts
146
+ end
147
+ private :_alternatives
148
+
149
+ # Returns an Alternative with the specified value.
150
+ #
151
+ # @example
152
+ # alternative(:red) == alternatives[0]
153
+ # alternative(:blue) == alternatives[2]
154
+ def alternative(value)
155
+ alternatives.find { |alt| alt.value == value }
156
+ end
157
+
158
+ # Defines an A/B test with two alternatives: false and true. This is the
159
+ # default pair of alternatives, so just syntactic sugar for those who love
160
+ # being explicit.
161
+ #
162
+ # @example
163
+ # ab_test "More bacon" do
164
+ # metrics :yummyness
165
+ # false_true
166
+ # end
167
+ #
168
+ def false_true
169
+ alternatives false, true
170
+ end
171
+ alias true_false false_true
172
+
173
+ # Chooses a value for this experiment. You probably want to use the
174
+ # Rails helper method ab_test instead.
175
+ #
176
+ # This method picks an alternative for the current identity and returns
177
+ # the alternative's value. It will consistently choose the same
178
+ # alternative for the same identity, and randomly split alternatives
179
+ # between different identities.
180
+ #
181
+ # @example
182
+ # color = experiment(:which_blue).choose
183
+ def choose
184
+ if @playground.collecting?
185
+ if active?
186
+ identity = identity()
187
+ index = connection.ab_showing(@id, identity)
188
+ unless index
189
+ index = alternative_for(identity)
190
+ connection.ab_add_participant @id, index, identity
191
+ check_completion!
192
+ end
193
+ else
194
+ index = connection.ab_get_outcome(@id) || alternative_for(identity)
195
+ end
196
+ else
197
+ identity = identity()
198
+ @showing ||= {}
199
+ @showing[identity] ||= alternative_for(identity)
200
+ end
201
+ @alternatives[index.to_i]
202
+ end
203
+
204
+ # Returns fingerprint (hash) for given alternative. Can be used to lookup
205
+ # alternative for experiment without revealing what values are available
206
+ # (e.g. choosing alternative from HTTP query parameter).
207
+ def fingerprint(alternative)
208
+ Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
209
+ end
210
+
211
+
212
+ # -- Testing --
213
+
214
+ # Forces this experiment to use a particular alternative. You'll want to
215
+ # use this from your test cases to test for the different alternatives.
216
+ #
217
+ # @example Setup test to red button
218
+ # setup do
219
+ # experiment(:button_color).select(:red)
220
+ # end
221
+ #
222
+ # def test_shows_red_button
223
+ # . . .
224
+ # end
225
+ #
226
+ # @example Use nil to clear selection
227
+ # teardown do
228
+ # experiment(:green_button).select(nil)
229
+ # end
230
+ def chooses(value)
231
+ if @playground.collecting?
232
+ if value.nil?
233
+ connection.ab_not_showing @id, identity
234
+ else
235
+ index = @alternatives.index(value)
236
+ raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
237
+ connection.ab_show @id, identity, index
238
+ end
239
+ else
240
+ @showing ||= {}
241
+ @showing[identity] = value.nil? ? nil : @alternatives.index(value)
242
+ end
243
+ self
244
+ end
245
+
246
+ # True if this alternative is currently showing (see #chooses).
247
+ def showing?(alternative)
248
+ identity = identity()
249
+ if @playground.collecting?
250
+ connection.ab_showing(@id, identity) == alternative.id
251
+ else
252
+ @showing ||= {}
253
+ @showing[identity] == alternative.id
254
+ end
255
+ end
256
+
257
+
258
+ # -- Reporting --
259
+
260
+ # Scores alternatives based on the current tracking data. This method
261
+ # returns a structure with the following attributes:
262
+ # [:alts] Ordered list of alternatives, populated with scoring info.
263
+ # [:base] Second best performing alternative.
264
+ # [:least] Least performing alternative (but more than zero conversion).
265
+ # [:choice] Choice alterntive, either the outcome or best alternative.
266
+ #
267
+ # Alternatives returned by this method are populated with the following
268
+ # attributes:
269
+ # [:z_score] Z-score (relative to the base alternative).
270
+ # [:probability] Probability (z-score mapped to 0, 90, 95, 99 or 99.9%).
271
+ # [:difference] Difference from the least performant altenative.
272
+ #
273
+ # The choice alternative is set only if its probability is higher or
274
+ # equal to the specified probability (default is 90%).
275
+ def score(probability = 90)
276
+ alts = alternatives
277
+ # sort by conversion rate to find second best and 2nd best
278
+ sorted = alts.sort_by(&:measure)
279
+ base = sorted[-2]
280
+ # calculate z-score
281
+ pc = base.measure
282
+ nc = base.participants
283
+ alts.each do |alt|
284
+ p = alt.measure
285
+ n = alt.participants
286
+ alt.z_score = (p - pc) / ((p * (1-p)/n) + (pc * (1-pc)/nc)).abs ** 0.5
287
+ alt.probability = AbTest.probability(alt.z_score)
288
+ end
289
+ # difference is measured from least performant
290
+ if least = sorted.find { |alt| alt.measure > 0 }
291
+ alts.each do |alt|
292
+ if alt.measure > least.measure
293
+ alt.difference = (alt.measure - least.measure) / least.measure * 100
294
+ end
295
+ end
296
+ end
297
+ # best alternative is one with highest conversion rate (best shot).
298
+ # choice alternative can only pick best if we have high probability (>90%).
299
+ best = sorted.last if sorted.last.measure > 0.0
300
+ choice = outcome ? alts[outcome.id] : (best && best.probability >= probability ? best : nil)
301
+ Struct.new(:alts, :best, :base, :least, :choice).new(alts, best, base, least, choice)
302
+ end
303
+
304
+ # Use the result of #score to derive a conclusion. Returns an
305
+ # array of claims.
306
+ def conclusion(score = score)
307
+ claims = []
308
+ participants = score.alts.inject(0) { |t,alt| t + alt.participants }
309
+ claims << case participants
310
+ when 0 ; "There are no participants in this experiment yet."
311
+ when 1 ; "There is one participant in this experiment."
312
+ else ; "There are #{participants} participants in this experiment."
313
+ end
314
+ # only interested in sorted alternatives with conversion
315
+ sorted = score.alts.select { |alt| alt.measure > 0.0 }.sort_by(&:measure).reverse
316
+ if sorted.size > 1
317
+ # start with alternatives that have conversion, from best to worst,
318
+ # then alternatives with no conversion.
319
+ sorted |= score.alts
320
+ # we want a result that's clearly better than 2nd best.
321
+ best, second = sorted[0], sorted[1]
322
+ if best.measure > second.measure
323
+ diff = ((best.measure - second.measure) / second.measure * 100).round
324
+ better = " (%d%% better than %s)" % [diff, second.name] if diff > 0
325
+ claims << "The best choice is %s: it converted at %.1f%%%s." % [best.name, best.measure * 100, better]
326
+ if best.probability >= 90
327
+ claims << "With %d%% probability this result is statistically significant." % score.best.probability
328
+ else
329
+ claims << "This result is not statistically significant, suggest you continue this experiment."
330
+ end
331
+ sorted.delete best
332
+ end
333
+ sorted.each do |alt|
334
+ if alt.measure > 0.0
335
+ claims << "%s converted at %.1f%%." % [alt.name.gsub(/^o/, "O"), alt.measure * 100]
336
+ else
337
+ claims << "%s did not convert." % alt.name.gsub(/^o/, "O")
338
+ end
339
+ end
340
+ else
341
+ claims << "This experiment did not run long enough to find a clear winner."
342
+ end
343
+ claims << "#{score.choice.name.gsub(/^o/, "O")} selected as the best alternative." if score.choice
344
+ claims
345
+ end
346
+
347
+
348
+ # -- Completion --
349
+
350
+ # Defines how the experiment can choose the optimal outcome on completion.
351
+ #
352
+ # By default, Vanity will take the best alternative (highest conversion
353
+ # rate) and use that as the outcome. You experiment may have different
354
+ # needs, maybe you want the least performing alternative, or factor cost
355
+ # in the equation?
356
+ #
357
+ # The default implementation reads like this:
358
+ # outcome_is do
359
+ # a, b = alternatives
360
+ # # a is expensive, only choose a if it performs 2x better than b
361
+ # a.measure > b.measure * 2 ? a : b
362
+ # end
363
+ def outcome_is(&block)
364
+ raise ArgumentError, "Missing block" unless block
365
+ raise "outcome_is already called on this experiment" if @outcome_is
366
+ @outcome_is = block
367
+ end
368
+
369
+ # Alternative chosen when this experiment completed.
370
+ def outcome
371
+ return unless @playground.collecting?
372
+ outcome = connection.ab_get_outcome(@id)
373
+ outcome && _alternatives[outcome]
374
+ end
375
+
376
+ def complete!
377
+ return unless @playground.collecting? && active?
378
+ super
379
+ if @outcome_is
380
+ begin
381
+ result = @outcome_is.call
382
+ outcome = result.id if result && result.experiment == self
383
+ rescue
384
+ # TODO: logging
385
+ end
386
+ else
387
+ best = score.best
388
+ outcome = best.id if best
389
+ end
390
+ # TODO: logging
391
+ connection.ab_set_outcome @id, outcome || 0
392
+ end
393
+
394
+
395
+ # -- Store/validate --
396
+
397
+ def destroy
398
+ connection.destroy_experiment @id
399
+ super
400
+ end
401
+
402
+ def save
403
+ true_false unless @alternatives
404
+ fail "Experiment #{name} needs at least two alternatives" unless @alternatives.size >= 2
405
+ super
406
+ if @metrics.nil? || @metrics.empty?
407
+ warn "Please use metrics method to explicitly state which metric you are measuring against."
408
+ metric = @playground.metrics[id] ||= Vanity::Metric.new(@playground, name)
409
+ @metrics = [metric]
410
+ end
411
+ @metrics.each do |metric|
412
+ metric.hook &method(:track!)
413
+ end
414
+ end
415
+
416
+ # Called when tracking associated metric.
417
+ def track!(metric_id, timestamp, count, *args)
418
+ return unless active?
419
+ identity = identity() rescue nil
420
+ if identity
421
+ return if connection.ab_showing(@id, identity)
422
+ index = alternative_for(identity)
423
+ connection.ab_add_conversion @id, index, identity, count
424
+ check_completion!
425
+ end
426
+ end
427
+
428
+ # If you are not embarrassed by the first version of your product, you’ve
429
+ # launched too late.
430
+ # -- Reid Hoffman, founder of LinkedIn
431
+
432
+ protected
433
+
434
+ # Used for testing.
435
+ def fake(values)
436
+ values.each do |value, (participants, conversions)|
437
+ conversions ||= participants
438
+ participants.times do |identity|
439
+ index = @alternatives.index(value)
440
+ raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
441
+ connection.ab_add_participant @id, index, "#{index}:#{identity}"
442
+ end
443
+ conversions.times do |identity|
444
+ index = @alternatives.index(value)
445
+ raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
446
+ connection.ab_add_conversion @id, index, "#{index}:#{identity}"
447
+ end
448
+ end
449
+ end
450
+
451
+ # Chooses an alternative for the identity and returns its index. This
452
+ # method always returns the same alternative for a given experiment and
453
+ # identity, and randomly distributed alternatives for each identity (in the
454
+ # same experiment).
455
+ def alternative_for(identity)
456
+ Digest::MD5.hexdigest("#{name}/#{identity}").to_i(17) % @alternatives.size
457
+ end
458
+
459
+ begin
460
+ a = 50.0
461
+ # Returns array of [z-score, percentage]
462
+ norm_dist = []
463
+ (0.0..3.1).step(0.01) { |x| norm_dist << [x, a += 1 / Math.sqrt(2 * Math::PI) * Math::E ** (-x ** 2 / 2)] }
464
+ # We're really only interested in 90%, 95%, 99% and 99.9%.
465
+ Z_TO_PROBABILITY = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |x,a| a >= pct }.first, pct] }.reverse
466
+ end
467
+
468
+ end
469
+
470
+
471
+ module Definition
472
+ # Define an A/B test with the given name. For example:
473
+ # ab_test "New Banner" do
474
+ # alternatives :red, :green, :blue
475
+ # end
476
+ def ab_test(name, &block)
477
+ define name, :ab_test, &block
478
+ end
479
+ end
480
+
481
+ end
482
+ end