vanity 3.0.2 → 4.0.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.
- checksums.yaml +4 -4
- data/.github/workflows/linting.yml +28 -0
- data/.github/workflows/test.yml +3 -6
- data/.rubocop.yml +114 -0
- data/.rubocop_todo.yml +67 -0
- data/Appraisals +9 -31
- data/CHANGELOG +13 -0
- data/Gemfile +7 -3
- data/Gemfile.lock +32 -4
- data/README.md +4 -9
- data/Rakefile +25 -24
- data/bin/vanity +1 -1
- data/doc/configuring.textile +1 -0
- data/gemfiles/rails52.gemfile +6 -3
- data/gemfiles/rails52.gemfile.lock +34 -9
- data/gemfiles/rails60.gemfile +6 -3
- data/gemfiles/rails60.gemfile.lock +34 -9
- data/gemfiles/rails61.gemfile +6 -3
- data/gemfiles/rails61.gemfile.lock +34 -9
- data/lib/generators/vanity/migration_generator.rb +5 -7
- data/lib/vanity/adapters/abstract_adapter.rb +43 -45
- data/lib/vanity/adapters/active_record_adapter.rb +30 -30
- data/lib/vanity/adapters/mock_adapter.rb +14 -18
- data/lib/vanity/adapters/mongodb_adapter.rb +73 -69
- data/lib/vanity/adapters/redis_adapter.rb +19 -27
- data/lib/vanity/adapters.rb +1 -1
- data/lib/vanity/autoconnect.rb +6 -7
- data/lib/vanity/commands/list.rb +7 -7
- data/lib/vanity/commands/report.rb +18 -22
- data/lib/vanity/configuration.rb +23 -19
- data/lib/vanity/connection.rb +12 -14
- data/lib/vanity/experiment/ab_test.rb +95 -79
- data/lib/vanity/experiment/alternative.rb +3 -5
- data/lib/vanity/experiment/base.rb +24 -19
- data/lib/vanity/experiment/bayesian_bandit_score.rb +7 -13
- data/lib/vanity/experiment/definition.rb +6 -6
- data/lib/vanity/frameworks/rails.rb +39 -39
- data/lib/vanity/frameworks.rb +2 -2
- data/lib/vanity/helpers.rb +1 -1
- data/lib/vanity/metric/active_record.rb +21 -19
- data/lib/vanity/metric/base.rb +22 -23
- data/lib/vanity/metric/google_analytics.rb +6 -9
- data/lib/vanity/metric/remote.rb +3 -5
- data/lib/vanity/playground.rb +3 -6
- data/lib/vanity/vanity.rb +8 -12
- data/lib/vanity/version.rb +1 -1
- data/test/adapters/active_record_adapter_test.rb +1 -5
- data/test/adapters/mock_adapter_test.rb +0 -2
- data/test/adapters/mongodb_adapter_test.rb +1 -5
- data/test/adapters/redis_adapter_test.rb +2 -3
- data/test/adapters/shared_tests.rb +9 -12
- data/test/autoconnect_test.rb +3 -3
- data/test/cli_test.rb +0 -1
- data/test/configuration_test.rb +18 -34
- data/test/connection_test.rb +3 -3
- data/test/dummy/Rakefile +1 -1
- data/test/dummy/app/controllers/use_vanity_controller.rb +12 -8
- data/test/dummy/app/mailers/vanity_mailer.rb +3 -3
- data/test/dummy/config/application.rb +1 -1
- data/test/dummy/config/boot.rb +3 -3
- data/test/dummy/config/environment.rb +1 -1
- data/test/dummy/config/environments/development.rb +0 -1
- data/test/dummy/config/environments/test.rb +1 -1
- data/test/dummy/config/initializers/session_store.rb +1 -1
- data/test/dummy/config/initializers/vanity.rb +3 -0
- data/test/dummy/config/vanity.yml +7 -0
- data/test/dummy/config.ru +1 -1
- data/test/dummy/script/rails +2 -2
- data/test/experiment/ab_test.rb +188 -154
- data/test/experiment/base_test.rb +48 -32
- data/test/frameworks/rails/action_controller_test.rb +25 -25
- data/test/frameworks/rails/action_mailer_test.rb +2 -2
- data/test/frameworks/rails/action_view_test.rb +5 -6
- data/test/frameworks/rails/rails_test.rb +147 -181
- data/test/helper_test.rb +2 -2
- data/test/metric/active_record_test.rb +174 -212
- data/test/metric/base_test.rb +21 -46
- data/test/metric/google_analytics_test.rb +17 -25
- data/test/metric/remote_test.rb +7 -10
- data/test/playground_test.rb +7 -15
- data/test/templates_test.rb +16 -20
- data/test/test_helper.rb +28 -29
- data/test/vanity_test.rb +4 -10
- data/test/web/rails/dashboard_test.rb +21 -21
- data/vanity.gemspec +8 -7
- metadata +32 -30
- data/gemfiles/rails42.gemfile +0 -33
- data/gemfiles/rails42.gemfile.lock +0 -265
- data/gemfiles/rails42_protected_attributes.gemfile +0 -34
- data/gemfiles/rails42_protected_attributes.gemfile.lock +0 -264
- data/gemfiles/rails51.gemfile +0 -33
- data/gemfiles/rails51.gemfile.lock +0 -285
@@ -10,7 +10,7 @@ module Vanity
|
|
10
10
|
# Convert z-score to probability.
|
11
11
|
def probability(score)
|
12
12
|
score = score.abs
|
13
|
-
probability = AbTest::Z_TO_PROBABILITY.find { |z,
|
13
|
+
probability = AbTest::Z_TO_PROBABILITY.find { |z, _p| score >= z }
|
14
14
|
probability ? probability.last : 0
|
15
15
|
end
|
16
16
|
|
@@ -48,6 +48,7 @@ module Vanity
|
|
48
48
|
class << self
|
49
49
|
define_method :default do |*args|
|
50
50
|
raise ArgumentError, "default has already been set to #{@default.inspect}" unless args.empty?
|
51
|
+
|
51
52
|
alternative(@default)
|
52
53
|
end
|
53
54
|
end
|
@@ -69,9 +70,10 @@ module Vanity
|
|
69
70
|
# has had #save invoked previous to any enabled= calls.
|
70
71
|
def enabled=(bool)
|
71
72
|
return unless @playground.collecting? && active?
|
73
|
+
|
72
74
|
if created_at.nil?
|
73
75
|
Vanity.logger.warn(
|
74
|
-
'DB has no created_at for this experiment! This most likely means'
|
76
|
+
'DB has no created_at for this experiment! This most likely means' \
|
75
77
|
'you didn\'t call #save before calling enabled=, which you should.'
|
76
78
|
)
|
77
79
|
else
|
@@ -146,10 +148,8 @@ module Vanity
|
|
146
148
|
# metrics :signup
|
147
149
|
# score_method :bayes_bandit_score
|
148
150
|
# end
|
149
|
-
def score_method(method=nil)
|
150
|
-
if method
|
151
|
-
@score_method = method
|
152
|
-
end
|
151
|
+
def score_method(method = nil)
|
152
|
+
@score_method = method if method
|
153
153
|
@score_method
|
154
154
|
end
|
155
155
|
|
@@ -172,7 +172,7 @@ module Vanity
|
|
172
172
|
# alternative for experiment without revealing what values are available
|
173
173
|
# (e.g. choosing alternative from HTTP query parameter).
|
174
174
|
def fingerprint(alternative)
|
175
|
-
Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10,10]
|
175
|
+
Digest::MD5.hexdigest("#{id}:#{alternative.id}")[-10, 10]
|
176
176
|
end
|
177
177
|
|
178
178
|
# Chooses a value for this experiment. You probably want to use the
|
@@ -185,10 +185,10 @@ module Vanity
|
|
185
185
|
#
|
186
186
|
# @example
|
187
187
|
# color = experiment(:which_blue).choose
|
188
|
-
def choose(request=nil)
|
188
|
+
def choose(request = nil)
|
189
189
|
if @playground.collecting?
|
190
190
|
if active?
|
191
|
-
if enabled?
|
191
|
+
if enabled? # rubocop:todo Style/GuardClause
|
192
192
|
return assignment_for_identity(request)
|
193
193
|
else
|
194
194
|
# Show the default if experiment is disabled.
|
@@ -229,7 +229,7 @@ module Vanity
|
|
229
229
|
# teardown do
|
230
230
|
# experiment(:green_button).chooses(nil)
|
231
231
|
# end
|
232
|
-
def chooses(value, request=nil)
|
232
|
+
def chooses(value, request = nil)
|
233
233
|
if @playground.collecting?
|
234
234
|
if value.nil?
|
235
235
|
connection.ab_not_showing @id, identity
|
@@ -238,8 +238,9 @@ module Vanity
|
|
238
238
|
save_assignment(identity, index, request) unless filter_visitor?(request, identity)
|
239
239
|
|
240
240
|
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
241
|
+
|
241
242
|
if (connection.ab_showing(@id, identity) && connection.ab_showing(@id, identity) != index) ||
|
242
|
-
|
243
|
+
alternative_for(identity) != index
|
243
244
|
connection.ab_show(@id, identity, index)
|
244
245
|
end
|
245
246
|
end
|
@@ -261,12 +262,11 @@ module Vanity
|
|
261
262
|
end
|
262
263
|
end
|
263
264
|
|
264
|
-
|
265
265
|
# -- Reporting --
|
266
266
|
|
267
267
|
def calculate_score
|
268
268
|
if respond_to?(score_method)
|
269
|
-
|
269
|
+
send(score_method)
|
270
270
|
else
|
271
271
|
score
|
272
272
|
end
|
@@ -298,22 +298,24 @@ module Vanity
|
|
298
298
|
alts.each do |alt|
|
299
299
|
p = alt.measure
|
300
300
|
n = alt.participants
|
301
|
-
alt.z_score = (p - pc) / ((p * (1-p)/n) + (pc * (1-pc)/nc)).abs
|
301
|
+
alt.z_score = (p - pc) / (((p * (1 - p) / n) + (pc * (1 - pc) / nc)).abs**0.5)
|
302
302
|
alt.probability = AbTest.probability(alt.z_score)
|
303
303
|
end
|
304
304
|
# difference is measured from least performant
|
305
|
-
if least = sorted.find { |alt| alt.measure > 0 }
|
305
|
+
if least = sorted.find { |alt| alt.measure > 0 } # rubocop:todo Lint/AssignmentInCondition
|
306
306
|
alts.each do |alt|
|
307
|
-
if alt.measure > least.measure
|
308
|
-
alt.difference = (alt.measure - least.measure) / least.measure * 100
|
309
|
-
end
|
307
|
+
alt.difference = (alt.measure - least.measure) / least.measure * 100 if alt.measure > least.measure
|
310
308
|
end
|
311
309
|
end
|
312
310
|
# best alternative is one with highest conversion rate (best shot).
|
313
311
|
# choice alternative can only pick best if we have high probability (>90%).
|
314
312
|
best = sorted.last if sorted.last.measure > 0.0
|
315
|
-
choice =
|
316
|
-
|
313
|
+
choice = if outcome
|
314
|
+
alts[outcome.id]
|
315
|
+
else
|
316
|
+
(best && best.probability >= probability ? best : nil)
|
317
|
+
end
|
318
|
+
Struct.new(:alts, :best, :base, :least, :choice, :method).new(alts, best, base, least, choice, :score) # rubocop:todo Lint/StructNewOverride
|
317
319
|
end
|
318
320
|
|
319
321
|
# Scores alternatives based on the current tracking data, using Bayesian
|
@@ -336,13 +338,13 @@ module Vanity
|
|
336
338
|
#
|
337
339
|
# The choice alternative is set only if its probability is higher or
|
338
340
|
# equal to the specified probability (default is 90%).
|
339
|
-
def bayes_bandit_score(
|
341
|
+
def bayes_bandit_score(_probability = 90)
|
340
342
|
begin
|
341
343
|
require "backports/1.9.1/kernel/define_singleton_method" if RUBY_VERSION < "1.9"
|
342
344
|
require "integration"
|
343
345
|
require "rubystats"
|
344
346
|
rescue LoadError
|
345
|
-
|
347
|
+
raise("to use bayes_bandit_score, install integration and rubystats gems")
|
346
348
|
end
|
347
349
|
|
348
350
|
begin
|
@@ -358,12 +360,12 @@ module Vanity
|
|
358
360
|
# array of claims.
|
359
361
|
def conclusion(score = score())
|
360
362
|
claims = []
|
361
|
-
participants = score.alts.inject(0) { |t,alt| t + alt.participants }
|
363
|
+
participants = score.alts.inject(0) { |t, alt| t + alt.participants }
|
362
364
|
claims << if participants.zero?
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
365
|
+
I18n.t('vanity.no_participants')
|
366
|
+
else
|
367
|
+
I18n.t('vanity.experiment_participants', count: participants)
|
368
|
+
end
|
367
369
|
# only interested in sorted alternatives with conversion
|
368
370
|
sorted = score.alts.select { |alt| alt.measure > 0.0 }.sort_by(&:measure).reverse
|
369
371
|
if sorted.size > 1
|
@@ -371,46 +373,45 @@ module Vanity
|
|
371
373
|
# then alternatives with no conversion.
|
372
374
|
sorted |= score.alts
|
373
375
|
# we want a result that's clearly better than 2nd best.
|
374
|
-
best
|
376
|
+
best = sorted[0]
|
377
|
+
second = sorted[1]
|
375
378
|
if best.measure > second.measure
|
376
379
|
diff = ((best.measure - second.measure) / second.measure * 100).round
|
377
|
-
better = I18n.t('vanity.better_alternative_than', :
|
378
|
-
claims << I18n.t('vanity.best_alternative_measure', :
|
379
|
-
if score.method == :bayes_bandit_score
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
end
|
391
|
-
end
|
380
|
+
better = I18n.t('vanity.better_alternative_than', probability: diff.to_i, alternative: second.name) if diff > 0
|
381
|
+
claims << I18n.t('vanity.best_alternative_measure', best_alternative: best.name, measure: format('%.1f', (best.measure * 100)), better_than: better)
|
382
|
+
claims << if score.method == :bayes_bandit_score
|
383
|
+
if best.probability >= 90
|
384
|
+
I18n.t('vanity.best_alternative_probability', probability: score.best.probability.to_i)
|
385
|
+
else
|
386
|
+
I18n.t('vanity.low_result_confidence')
|
387
|
+
end
|
388
|
+
elsif best.probability >= 90
|
389
|
+
I18n.t('vanity.best_alternative_is_significant', probability: score.best.probability.to_i)
|
390
|
+
else
|
391
|
+
I18n.t('vanity.result_isnt_significant')
|
392
|
+
end
|
392
393
|
sorted.delete best
|
393
394
|
end
|
394
395
|
sorted.each do |alt|
|
395
|
-
if alt.measure > 0.0
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
396
|
+
claims << if alt.measure > 0.0
|
397
|
+
I18n.t('vanity.converted_percentage', alternative: alt.name.sub(/^\w/, &:upcase), percentage: format('%.1f', (alt.measure * 100)))
|
398
|
+
else
|
399
|
+
I18n.t('vanity.didnt_convert', alternative: alt.name.sub(/^\w/, &:upcase))
|
400
|
+
end
|
400
401
|
end
|
401
402
|
else
|
402
403
|
claims << I18n.t('vanity.no_clear_winner')
|
403
404
|
end
|
404
|
-
claims << I18n.t('vanity.selected_as_best', :
|
405
|
+
claims << I18n.t('vanity.selected_as_best', alternative: score.choice.name.sub(/^\w/, &:upcase)) if score.choice
|
405
406
|
claims
|
406
407
|
end
|
407
408
|
|
408
409
|
# -- Unequal probability assignments --
|
409
410
|
|
410
|
-
def set_alternative_probabilities(alternative_probabilities)
|
411
|
+
def set_alternative_probabilities(alternative_probabilities) # rubocop:todo Naming/AccessorMethodName
|
411
412
|
# create @use_probabilities as a function to go from [0,1] to outcome
|
412
413
|
cumulative_probability = 0.0
|
413
|
-
new_probabilities = alternative_probabilities.map {|am| [am, (cumulative_probability += am.probability)/100.0]}
|
414
|
+
new_probabilities = alternative_probabilities.map { |am| [am, (cumulative_probability += am.probability) / 100.0] }
|
414
415
|
@use_probabilities = new_probabilities
|
415
416
|
end
|
416
417
|
|
@@ -425,7 +426,7 @@ module Vanity
|
|
425
426
|
# end
|
426
427
|
#
|
427
428
|
# puts "The experiment will automatically rebalance after every " + experiment(:simple).description + " users are assigned."
|
428
|
-
def rebalance_frequency(rf = nil)
|
429
|
+
def rebalance_frequency(rf = nil) # rubocop:todo Naming/MethodParameterName
|
429
430
|
if rf
|
430
431
|
@assignments_since_rebalancing = 0
|
431
432
|
@rebalance_frequency = rf
|
@@ -437,10 +438,9 @@ module Vanity
|
|
437
438
|
# Force experiment to rebalance.
|
438
439
|
def rebalance!
|
439
440
|
return unless @playground.collecting?
|
441
|
+
|
440
442
|
score_results = bayes_bandit_score
|
441
|
-
if score_results.method == :bayes_bandit_score
|
442
|
-
set_alternative_probabilities score_results.alts
|
443
|
-
end
|
443
|
+
set_alternative_probabilities score_results.alts if score_results.method == :bayes_bandit_score
|
444
444
|
end
|
445
445
|
|
446
446
|
# -- Completion --
|
@@ -461,12 +461,14 @@ module Vanity
|
|
461
461
|
def outcome_is(&block)
|
462
462
|
raise ArgumentError, "Missing block" unless block
|
463
463
|
raise "outcome_is already called on this experiment" if defined?(@outcome_is)
|
464
|
+
|
464
465
|
@outcome_is = block
|
465
466
|
end
|
466
467
|
|
467
468
|
# Alternative chosen when this experiment completed.
|
468
469
|
def outcome
|
469
470
|
return unless @playground.collecting?
|
471
|
+
|
470
472
|
outcome = connection.ab_get_outcome(@id)
|
471
473
|
outcome && alternatives[outcome]
|
472
474
|
end
|
@@ -474,6 +476,7 @@ module Vanity
|
|
474
476
|
def complete!(outcome = nil)
|
475
477
|
# This statement is equivalent to: return unless collecting?
|
476
478
|
return unless @playground.collecting? && active?
|
479
|
+
|
477
480
|
self.enabled = false
|
478
481
|
super
|
479
482
|
|
@@ -481,8 +484,8 @@ module Vanity
|
|
481
484
|
if defined?(@outcome_is)
|
482
485
|
begin
|
483
486
|
result = @outcome_is.call
|
484
|
-
outcome = result.id if Alternative
|
485
|
-
rescue => e
|
487
|
+
outcome = result.id if result.is_a?(Alternative) && result.experiment == self
|
488
|
+
rescue StandardError => e
|
486
489
|
Vanity.logger.warn("Error in AbTest#complete!: #{e}")
|
487
490
|
end
|
488
491
|
else
|
@@ -494,7 +497,6 @@ module Vanity
|
|
494
497
|
connection.ab_set_outcome(@id, outcome || 0)
|
495
498
|
end
|
496
499
|
|
497
|
-
|
498
500
|
# -- Store/validate --
|
499
501
|
|
500
502
|
def destroy
|
@@ -505,6 +507,7 @@ module Vanity
|
|
505
507
|
# clears all collected data for the experiment
|
506
508
|
def reset
|
507
509
|
return unless @playground.collecting?
|
510
|
+
|
508
511
|
connection.destroy_experiment(@id)
|
509
512
|
connection.set_experiment_created_at(@id, Time.now)
|
510
513
|
@outcome = @completed_at = nil
|
@@ -523,7 +526,8 @@ module Vanity
|
|
523
526
|
end
|
524
527
|
@saved = true
|
525
528
|
true_false unless defined?(@alternatives)
|
526
|
-
|
529
|
+
raise "Experiment #{name} needs at least two alternatives" unless @alternatives.size >= 2
|
530
|
+
|
527
531
|
if !@is_default_set
|
528
532
|
default(@alternatives.first)
|
529
533
|
Vanity.logger.warn("No default alternative specified; choosing #{@default} as default.")
|
@@ -540,17 +544,23 @@ module Vanity
|
|
540
544
|
@metrics = [default_metric]
|
541
545
|
end
|
542
546
|
@metrics.each do |metric|
|
543
|
-
metric.hook(&method(:track!))
|
547
|
+
metric.hook(&method(:track!)) # rubocop:todo Performance/MethodObjectAsBlock
|
544
548
|
end
|
545
549
|
end
|
546
550
|
|
547
551
|
# Called via a hook by the associated metric.
|
548
|
-
def track!(
|
552
|
+
def track!(_metric_id, _timestamp, count, *args)
|
549
553
|
return unless active? && enabled?
|
554
|
+
|
550
555
|
identity = args.last[:identity] if args.last.is_a?(Hash)
|
551
|
-
identity ||=
|
552
|
-
|
556
|
+
identity ||= begin
|
557
|
+
identity()
|
558
|
+
rescue StandardError
|
559
|
+
nil
|
560
|
+
end
|
561
|
+
if identity # rubocop:todo Style/GuardClause
|
553
562
|
return if connection.ab_showing(@id, identity)
|
563
|
+
|
554
564
|
index = alternative_for(identity)
|
555
565
|
connection.ab_add_conversion(@id, index, identity, count)
|
556
566
|
check_completion!
|
@@ -561,7 +571,7 @@ module Vanity
|
|
561
571
|
# launched too late.
|
562
572
|
# -- Reid Hoffman, founder of LinkedIn
|
563
573
|
|
564
|
-
|
574
|
+
protected
|
565
575
|
|
566
576
|
# Used for testing.
|
567
577
|
def fake(values)
|
@@ -570,11 +580,13 @@ module Vanity
|
|
570
580
|
participants.times do |identity|
|
571
581
|
index = @alternatives.index(value)
|
572
582
|
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
583
|
+
|
573
584
|
connection.ab_add_participant @id, index, "#{index}:#{identity}"
|
574
585
|
end
|
575
586
|
conversions.times do |identity|
|
576
587
|
index = @alternatives.index(value)
|
577
588
|
raise ArgumentError, "No alternative #{value.inspect} for #{name}" unless index
|
589
|
+
|
578
590
|
connection.ab_add_conversion @id, index, "#{index}:#{identity}"
|
579
591
|
end
|
580
592
|
end
|
@@ -605,7 +617,7 @@ module Vanity
|
|
605
617
|
return existing_assignment if existing_assignment
|
606
618
|
|
607
619
|
if @use_probabilities
|
608
|
-
random_outcome = rand
|
620
|
+
random_outcome = rand
|
609
621
|
@use_probabilities.each do |alternative, max_prob|
|
610
622
|
return alternative.id if random_outcome < max_prob
|
611
623
|
end
|
@@ -617,13 +629,15 @@ module Vanity
|
|
617
629
|
# Saves the assignment of an alternative to a person and performs the
|
618
630
|
# necessary housekeeping. Ignores repeat identities and filters using
|
619
631
|
# Playground#request_filter.
|
620
|
-
def save_assignment(identity, index,
|
632
|
+
def save_assignment(identity, index, _request)
|
621
633
|
return if index == connection.ab_showing(@id, identity)
|
634
|
+
return if connection.ab_seen @id, identity, index
|
622
635
|
|
623
|
-
call_on_assignment_if_available(identity, index)
|
624
636
|
rebalance_if_necessary!
|
625
637
|
|
626
638
|
connection.ab_add_participant(@id, index, identity)
|
639
|
+
call_on_assignment_if_available(identity, index)
|
640
|
+
|
627
641
|
check_completion!
|
628
642
|
end
|
629
643
|
|
@@ -633,18 +647,23 @@ module Vanity
|
|
633
647
|
end
|
634
648
|
|
635
649
|
def call_on_assignment_if_available(identity, index)
|
650
|
+
assignment = alternatives[index]
|
651
|
+
|
636
652
|
# if we have an on_assignment block, call it on new assignments
|
637
653
|
if defined?(@on_assignment_block) && @on_assignment_block
|
638
|
-
assignment
|
639
|
-
|
640
|
-
|
641
|
-
end
|
654
|
+
@on_assignment_block.call(Vanity.context, identity, assignment, self)
|
655
|
+
|
656
|
+
return
|
642
657
|
end
|
658
|
+
|
659
|
+
return unless Vanity.configuration.on_assignment.is_a?(Proc)
|
660
|
+
|
661
|
+
Vanity.configuration.on_assignment.call(Vanity.context, identity, assignment, self)
|
643
662
|
end
|
644
663
|
|
645
664
|
def rebalance_if_necessary!
|
646
665
|
# if we are rebalancing probabilities, keep track of how long it has been since we last rebalanced
|
647
|
-
if defined?(@rebalance_frequency) && @rebalance_frequency
|
666
|
+
if defined?(@rebalance_frequency) && @rebalance_frequency # rubocop:todo Style/GuardClause
|
648
667
|
@assignments_since_rebalancing += 1
|
649
668
|
if @assignments_since_rebalancing >= @rebalance_frequency
|
650
669
|
@assignments_since_rebalancing = 0
|
@@ -653,13 +672,13 @@ module Vanity
|
|
653
672
|
end
|
654
673
|
end
|
655
674
|
|
656
|
-
def has_alternative_weights?(args)
|
675
|
+
def has_alternative_weights?(args) # rubocop:todo Naming/PredicateName
|
657
676
|
(!defined?(@alternatives) || @alternatives.nil?) && args.size == 1 && args[0].is_a?(Hash)
|
658
677
|
end
|
659
678
|
|
660
679
|
def build_alternatives_with_weights(args)
|
661
680
|
@alternatives = args[0]
|
662
|
-
sum_of_probability = @alternatives.values.reduce(0) { |a,b| a+b }
|
681
|
+
sum_of_probability = @alternatives.values.reduce(0) { |a, b| a + b }
|
663
682
|
cumulative_probability = 0.0
|
664
683
|
@use_probabilities = []
|
665
684
|
result = []
|
@@ -684,14 +703,12 @@ module Vanity
|
|
684
703
|
avg = 50.0
|
685
704
|
# Returns array of [z-score, percentage]
|
686
705
|
norm_dist = []
|
687
|
-
(0.0..3.1).step(0.01) { |x| norm_dist << [x, avg += 1 / Math.sqrt(2 * Math::PI) * Math::E
|
706
|
+
(0.0..3.1).step(0.01) { |x| norm_dist << [x, avg += 1 / Math.sqrt(2 * Math::PI) * (Math::E**(-x**2 / 2))] }
|
688
707
|
# We're really only interested in 90%, 95%, 99% and 99.9%.
|
689
|
-
Z_TO_PROBABILITY = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |
|
708
|
+
Z_TO_PROBABILITY = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |_x, a| a >= pct }.first, pct] }.reverse
|
690
709
|
end
|
691
|
-
|
692
710
|
end
|
693
711
|
|
694
|
-
|
695
712
|
module Definition
|
696
713
|
# Define an A/B test with the given name. For example:
|
697
714
|
# ab_test "New Banner" do
|
@@ -701,6 +718,5 @@ module Vanity
|
|
701
718
|
define name, :ab_test, &block
|
702
719
|
end
|
703
720
|
end
|
704
|
-
|
705
721
|
end
|
706
722
|
end
|
@@ -1,13 +1,11 @@
|
|
1
1
|
module Vanity
|
2
2
|
module Experiment
|
3
|
-
|
4
3
|
# One of several alternatives in an A/B test (see AbTest#alternatives).
|
5
4
|
class Alternative
|
6
|
-
|
7
|
-
def initialize(experiment, id, value) #, participants, converted, conversions)
|
5
|
+
def initialize(experiment, id, value) # , participants, converted, conversions)
|
8
6
|
@experiment = experiment
|
9
7
|
@id = id
|
10
|
-
@name = I18n.t('vanity.option_number', :
|
8
|
+
@name = I18n.t('vanity.option_number', char: (@id + 65).chr.upcase)
|
11
9
|
@value = value
|
12
10
|
end
|
13
11
|
|
@@ -56,7 +54,7 @@ module Vanity
|
|
56
54
|
|
57
55
|
# Conversion rate calculated as converted/participants
|
58
56
|
def conversion_rate
|
59
|
-
@conversion_rate ||= (participants > 0 ? converted.to_f/participants
|
57
|
+
@conversion_rate ||= (participants > 0 ? converted.to_f / participants : 0.0)
|
60
58
|
end
|
61
59
|
|
62
60
|
# The measure we use to order (sort) alternatives and decide which one
|
@@ -4,40 +4,41 @@ module Vanity
|
|
4
4
|
module Experiment
|
5
5
|
# Base class that all experiment types are derived from.
|
6
6
|
class Base
|
7
|
-
|
8
7
|
class << self
|
9
8
|
# Returns the type of this class as a symbol (e.g. AbTest becomes
|
10
9
|
# ab_test).
|
11
10
|
def type
|
12
|
-
name.split("::").last.gsub(/([a-z])([A-Z])/) { "#{
|
11
|
+
name.split("::").last.gsub(/([a-z])([A-Z])/) { "#{Regexp.last_match(1)}_#{Regexp.last_match(2)}" }.gsub(/([A-Z])([A-Z][a-z])/) { "#{Regexp.last_match(1)}_#{Regexp.last_match(2)}" }.downcase
|
13
12
|
end
|
14
13
|
|
15
14
|
# Playground uses this to load experiment definitions.
|
16
15
|
def load(playground, stack, file)
|
17
|
-
|
16
|
+
raise "Circular dependency detected: #{stack.join('=>')}=>#{file}" if stack.include?(file)
|
17
|
+
|
18
18
|
source = File.read(file)
|
19
19
|
stack.push file
|
20
20
|
id = File.basename(file, ".rb").downcase.gsub(/\W/, "_").to_sym
|
21
21
|
context = Object.new
|
22
22
|
context.instance_eval do
|
23
23
|
extend Definition
|
24
|
-
experiment = eval(source, context.new_binding(playground, id), file)
|
25
|
-
|
24
|
+
experiment = eval(source, context.new_binding(playground, id), file) # rubocop:todo Security/Eval
|
25
|
+
raise NameError.new("Expected #{file} to define experiment #{id}", id) unless playground.experiments[id]
|
26
|
+
|
26
27
|
return experiment
|
27
28
|
end
|
28
|
-
rescue
|
29
|
+
rescue StandardError
|
29
30
|
error = NameError.exception($!.message, id)
|
30
31
|
error.set_backtrace $!.backtrace
|
31
32
|
raise error
|
32
33
|
ensure
|
33
34
|
stack.pop
|
34
35
|
end
|
35
|
-
|
36
36
|
end
|
37
37
|
|
38
38
|
def initialize(playground, id, name, options = nil)
|
39
39
|
@playground = playground
|
40
|
-
@id
|
40
|
+
@id = id.to_sym
|
41
|
+
@name = name
|
41
42
|
@options = options || {}
|
42
43
|
@identify_block = method(:default_identify)
|
43
44
|
@on_assignment_block = nil
|
@@ -46,7 +47,7 @@ module Vanity
|
|
46
47
|
# Human readable experiment name (first argument you pass when creating a
|
47
48
|
# new experiment).
|
48
49
|
attr_reader :name
|
49
|
-
alias
|
50
|
+
alias to_s name
|
50
51
|
|
51
52
|
# Unique identifier, derived from name experiment name, e.g. "Green
|
52
53
|
# Button" becomes :green_button.
|
@@ -78,7 +79,8 @@ module Vanity
|
|
78
79
|
# end
|
79
80
|
# end
|
80
81
|
def identify(&block)
|
81
|
-
|
82
|
+
raise "Missing block" unless block
|
83
|
+
|
82
84
|
@identify_block = block
|
83
85
|
end
|
84
86
|
|
@@ -92,7 +94,8 @@ module Vanity
|
|
92
94
|
# end
|
93
95
|
# end
|
94
96
|
def on_assignment(&block)
|
95
|
-
|
97
|
+
raise "Missing block" unless block
|
98
|
+
|
96
99
|
@on_assignment_block = block
|
97
100
|
end
|
98
101
|
|
@@ -109,7 +112,6 @@ module Vanity
|
|
109
112
|
@description if defined?(@description)
|
110
113
|
end
|
111
114
|
|
112
|
-
|
113
115
|
# -- Experiment completion --
|
114
116
|
|
115
117
|
# Define experiment completion condition. For example:
|
@@ -119,15 +121,17 @@ module Vanity
|
|
119
121
|
def complete_if(&block)
|
120
122
|
raise ArgumentError, "Missing block" unless block
|
121
123
|
raise "complete_if already called on this experiment" if defined?(@complete_block)
|
124
|
+
|
122
125
|
@complete_block = block
|
123
126
|
end
|
124
127
|
|
125
128
|
# Force experiment to complete.
|
126
129
|
# @param optional integer id of the alternative that is the decided
|
127
130
|
# outcome of the experiment
|
128
|
-
def complete!(
|
131
|
+
def complete!(_outcome = nil)
|
129
132
|
@playground.logger.info "vanity: completed experiment #{id}"
|
130
133
|
return unless @playground.collecting?
|
134
|
+
|
131
135
|
connection.set_experiment_completed_at @id, Time.now
|
132
136
|
@completed_at = connection.get_experiment_completed_at(@id)
|
133
137
|
end
|
@@ -153,6 +157,7 @@ module Vanity
|
|
153
157
|
# Called by Playground to save the experiment definition.
|
154
158
|
def save
|
155
159
|
return unless @playground.collecting?
|
160
|
+
|
156
161
|
connection.set_experiment_created_at @id, Time.now
|
157
162
|
end
|
158
163
|
|
@@ -165,12 +170,13 @@ module Vanity
|
|
165
170
|
# end
|
166
171
|
#
|
167
172
|
def reject(&block)
|
168
|
-
|
173
|
+
raise "Missing block" unless block
|
169
174
|
raise "filter already called on this experiment" if @request_filter_block
|
175
|
+
|
170
176
|
@request_filter_block = block
|
171
177
|
end
|
172
178
|
|
173
|
-
|
179
|
+
protected
|
174
180
|
|
175
181
|
def identity
|
176
182
|
@identify_block.call(Vanity.context)
|
@@ -179,16 +185,17 @@ module Vanity
|
|
179
185
|
def default_identify(context)
|
180
186
|
raise "No Vanity.context" unless context
|
181
187
|
raise "Vanity.context does not respond to vanity_identity" unless context.respond_to?(:vanity_identity, true)
|
188
|
+
|
182
189
|
context.send(:vanity_identity) or raise "Vanity.context.vanity_identity - no identity"
|
183
190
|
end
|
184
191
|
|
185
192
|
# Derived classes call this after state changes that may lead to
|
186
193
|
# experiment completing.
|
187
194
|
def check_completion!
|
188
|
-
if defined?(@complete_block) && @complete_block
|
195
|
+
if defined?(@complete_block) && @complete_block # rubocop:todo Style/GuardClause
|
189
196
|
begin
|
190
197
|
complete! if @complete_block.call
|
191
|
-
rescue => e
|
198
|
+
rescue StandardError => e
|
192
199
|
Vanity.logger.warn("Error in Vanity::Experiment::Base: #{e}")
|
193
200
|
end
|
194
201
|
end
|
@@ -206,11 +213,9 @@ module Vanity
|
|
206
213
|
def connection
|
207
214
|
@playground.connection
|
208
215
|
end
|
209
|
-
|
210
216
|
end
|
211
217
|
end
|
212
218
|
|
213
219
|
class NoExperimentError < NameError
|
214
220
|
end
|
215
|
-
|
216
221
|
end
|