moses-vanity 1.7.1

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