moses-vanity 1.7.1

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