abingo 1.1.0
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.
- data/.gitignore +1 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +92 -0
- data/MIT-LICENSE +20 -0
- data/README +109 -0
- data/Rakefile +24 -0
- data/abingo.gemspec +19 -0
- data/generators/abingo_migration/abingo_migration_generator.rb +24 -0
- data/generators/abingo_migration/templates/abingo_migration.rb +31 -0
- data/lib/abingo.rb +315 -0
- data/lib/abingo/alternative.rb +25 -0
- data/lib/abingo/controller/dashboard.rb +29 -0
- data/lib/abingo/conversion_rate.rb +9 -0
- data/lib/abingo/experiment.rb +107 -0
- data/lib/abingo/rails/controller/dashboard.rb +13 -0
- data/lib/abingo/statistics.rb +90 -0
- data/lib/abingo/version.rb +3 -0
- data/lib/abingo/views/dashboard/_experiment.erb +43 -0
- data/lib/abingo/views/dashboard/index.erb +20 -0
- data/lib/abingo_sugar.rb +49 -0
- data/lib/abingo_view_helper.rb +45 -0
- data/strip.rb +11 -0
- data/test/abingo_test.rb +187 -0
- data/test/test_helper.rb +34 -0
- data/uninstall.rb +1 -0
- metadata +89 -0
data/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
.svn
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
PATH
|
2
|
+
remote: .
|
3
|
+
specs:
|
4
|
+
abingo (1.1.0)
|
5
|
+
rails (~> 3.0)
|
6
|
+
|
7
|
+
GEM
|
8
|
+
remote: https://rubygems.org/
|
9
|
+
specs:
|
10
|
+
actionmailer (3.2.9)
|
11
|
+
actionpack (= 3.2.9)
|
12
|
+
mail (~> 2.4.4)
|
13
|
+
actionpack (3.2.9)
|
14
|
+
activemodel (= 3.2.9)
|
15
|
+
activesupport (= 3.2.9)
|
16
|
+
builder (~> 3.0.0)
|
17
|
+
erubis (~> 2.7.0)
|
18
|
+
journey (~> 1.0.4)
|
19
|
+
rack (~> 1.4.0)
|
20
|
+
rack-cache (~> 1.2)
|
21
|
+
rack-test (~> 0.6.1)
|
22
|
+
sprockets (~> 2.2.1)
|
23
|
+
activemodel (3.2.9)
|
24
|
+
activesupport (= 3.2.9)
|
25
|
+
builder (~> 3.0.0)
|
26
|
+
activerecord (3.2.9)
|
27
|
+
activemodel (= 3.2.9)
|
28
|
+
activesupport (= 3.2.9)
|
29
|
+
arel (~> 3.0.2)
|
30
|
+
tzinfo (~> 0.3.29)
|
31
|
+
activeresource (3.2.9)
|
32
|
+
activemodel (= 3.2.9)
|
33
|
+
activesupport (= 3.2.9)
|
34
|
+
activesupport (3.2.9)
|
35
|
+
i18n (~> 0.6)
|
36
|
+
multi_json (~> 1.0)
|
37
|
+
arel (3.0.2)
|
38
|
+
builder (3.0.4)
|
39
|
+
erubis (2.7.0)
|
40
|
+
hike (1.2.1)
|
41
|
+
i18n (0.6.1)
|
42
|
+
journey (1.0.4)
|
43
|
+
json (1.7.5)
|
44
|
+
mail (2.4.4)
|
45
|
+
i18n (>= 0.4.0)
|
46
|
+
mime-types (~> 1.16)
|
47
|
+
treetop (~> 1.4.8)
|
48
|
+
mime-types (1.19)
|
49
|
+
multi_json (1.3.7)
|
50
|
+
polyglot (0.3.3)
|
51
|
+
rack (1.4.1)
|
52
|
+
rack-cache (1.2)
|
53
|
+
rack (>= 0.4)
|
54
|
+
rack-ssl (1.3.2)
|
55
|
+
rack
|
56
|
+
rack-test (0.6.2)
|
57
|
+
rack (>= 1.0)
|
58
|
+
rails (3.2.9)
|
59
|
+
actionmailer (= 3.2.9)
|
60
|
+
actionpack (= 3.2.9)
|
61
|
+
activerecord (= 3.2.9)
|
62
|
+
activeresource (= 3.2.9)
|
63
|
+
activesupport (= 3.2.9)
|
64
|
+
bundler (~> 1.0)
|
65
|
+
railties (= 3.2.9)
|
66
|
+
railties (3.2.9)
|
67
|
+
actionpack (= 3.2.9)
|
68
|
+
activesupport (= 3.2.9)
|
69
|
+
rack-ssl (~> 1.3.2)
|
70
|
+
rake (>= 0.8.7)
|
71
|
+
rdoc (~> 3.4)
|
72
|
+
thor (>= 0.14.6, < 2.0)
|
73
|
+
rake (10.0.2)
|
74
|
+
rdoc (3.12)
|
75
|
+
json (~> 1.4)
|
76
|
+
sprockets (2.2.1)
|
77
|
+
hike (~> 1.2)
|
78
|
+
multi_json (~> 1.0)
|
79
|
+
rack (~> 1.0)
|
80
|
+
tilt (~> 1.1, != 1.3.0)
|
81
|
+
thor (0.16.0)
|
82
|
+
tilt (1.3.3)
|
83
|
+
treetop (1.4.12)
|
84
|
+
polyglot
|
85
|
+
polyglot (>= 0.3.1)
|
86
|
+
tzinfo (0.3.35)
|
87
|
+
|
88
|
+
PLATFORMS
|
89
|
+
ruby
|
90
|
+
|
91
|
+
DEPENDENCIES
|
92
|
+
abingo!
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Patrick McKenzie
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
A/Bingo Version 1.0.3
|
2
|
+
======
|
3
|
+
|
4
|
+
Rails A/B testing. One minute to install. One line to set up a new A/B test.
|
5
|
+
One line to track conversion.
|
6
|
+
|
7
|
+
For usage notes, see: http://www.bingocardcreator.com/abingo
|
8
|
+
|
9
|
+
Installation instructions are below usage examples.
|
10
|
+
|
11
|
+
Key default features:
|
12
|
+
-- Conversions only tracked once per individual.
|
13
|
+
-- Conversions only tracked if individual saw test.
|
14
|
+
-- Same individual ALWAYS sees same alternative for same test.
|
15
|
+
-- Syntax sugar. Specify alternatives as a range, array,
|
16
|
+
hash of alternative to weighting, or just let it default to true or false.
|
17
|
+
-- A simple z-test of statistical significance, with output so clear anyone in your organization
|
18
|
+
can understand it.
|
19
|
+
|
20
|
+
Example: View
|
21
|
+
|
22
|
+
<% ab_test("login_button", ["/images/button1.jpg", "/images/button2.jpg"]) do |button_file| %>
|
23
|
+
<%= img_tag(button_file, :alt => "Login!") %>
|
24
|
+
<% end %>
|
25
|
+
|
26
|
+
Example: Controller
|
27
|
+
|
28
|
+
def register_new_user
|
29
|
+
#See what level of free points maximizes users' decision to buy replacement points.
|
30
|
+
@starter_points = ab_test("new_user_free_points", [100, 200, 300])
|
31
|
+
end
|
32
|
+
|
33
|
+
Example: Controller
|
34
|
+
|
35
|
+
def registration
|
36
|
+
if (ab_test("send_welcome_email"), :conversion => "purchase")
|
37
|
+
#send the email, track to see if it later increases conversion to full version
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
Example: Conversion tracking (in a controller!)
|
42
|
+
|
43
|
+
def buy_new_points
|
44
|
+
#some business logic
|
45
|
+
bingo!("buy_new_points") #Either a conversion named with :conversion or a test name.
|
46
|
+
end
|
47
|
+
|
48
|
+
Example: Conversion tracking (in a view)
|
49
|
+
|
50
|
+
Thanks for signing up, dude! <% bingo!("signup_page_redesign") >
|
51
|
+
|
52
|
+
Example: Statistical Significance Testing
|
53
|
+
|
54
|
+
Abingo::Experiment.last.describe_result_in_words
|
55
|
+
=> "The best alternative you have is: [0], which had 130 conversions from 5000 participants (2.60%).
|
56
|
+
The other alternative was [1], which had 1800 conversions from 100000 participants (1.80%).
|
57
|
+
This difference is 99.9% likely to be statistically significant, which means you can be extremely
|
58
|
+
confident that it is the result of your alternatives actually mattering, rather than being due to
|
59
|
+
random chance. However, this doesn't say anything about how much the first alternative is really
|
60
|
+
likely to be better by."
|
61
|
+
|
62
|
+
Installation
|
63
|
+
=======
|
64
|
+
|
65
|
+
1) REQUIRED: You'll need to generate a DB migration to prepare two tables,
|
66
|
+
then migrate your database. (Note: slight edits required if you use the table names
|
67
|
+
"experiments" or "alternatives" at present.) Note: if you are upgrading to A/Bingo 1.0.0, you'll
|
68
|
+
want to do this again.
|
69
|
+
|
70
|
+
ruby script/generate abingo_migration
|
71
|
+
rake db:migrate
|
72
|
+
|
73
|
+
2) REQUIRED: You need to tell A/Bingo a user's identity so that it knows who is
|
74
|
+
who if they come back to a test. (The same identity will ALWAYS see the same
|
75
|
+
alternative for the same test.) How you do this is up to you -- I suggest integrating
|
76
|
+
with your login/account infrastructure. The simplest thing that can possibly work
|
77
|
+
|
78
|
+
#Somewhere in application.rb
|
79
|
+
before_filter :set_abingo_identity
|
80
|
+
|
81
|
+
def set_abingo_identity
|
82
|
+
if (session[:abingo_identity])
|
83
|
+
Abingo.identity = session[:abingo_identity]
|
84
|
+
else
|
85
|
+
session[:abingo_identity] = Abingo.identity = rand(10 ** 10).to_i
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
3) RECOMMENDED: A/Bingo makes HEAVY use of the cache to reduce load on the
|
90
|
+
database and share potentially long-lived "temporary" data, such as what alternative
|
91
|
+
a given visitor should be shown for a particular test. You SHOULD use a cache
|
92
|
+
which is shared across all Rails processes -- that probably means MemcachedStore or RedisStore.
|
93
|
+
|
94
|
+
You PROBABLY SHOULD use a persistent cache in case you need to restart your
|
95
|
+
machine. This is an amazingly good use case for MemcacheDB, so if you want to
|
96
|
+
try playing with that, Google it. (Sets up VERY easily on the newer Ubuntu distros.)
|
97
|
+
|
98
|
+
If you can't use a persistent cache, you're probably still OK if Memcached very
|
99
|
+
rarely needs to be restarted. If the cache gets flushed, you will double-count
|
100
|
+
entrants to a particular experiment and possibly double-count conversions, but
|
101
|
+
that may not be the worse thing in the world.
|
102
|
+
|
103
|
+
A/Bingo defaults to using the same cache store as Rails. If you want to change it
|
104
|
+
|
105
|
+
#production.rb
|
106
|
+
Abingo.cache = ActiveSupport::Cache::MemCacheStore.new("cache.example.com:12345") #best if really memcacheDB
|
107
|
+
|
108
|
+
|
109
|
+
Copyright (c) 2009-2010 Patrick McKenzie, released under the MIT license
|
data/Rakefile
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
require "bundler/gem_tasks"
|
3
|
+
require 'rake/testtask'
|
4
|
+
require 'rake/rdoctask'
|
5
|
+
|
6
|
+
desc 'Default: run unit tests.'
|
7
|
+
task :default => :test
|
8
|
+
|
9
|
+
desc 'Test the abingo plugin.'
|
10
|
+
Rake::TestTask.new(:test) do |t|
|
11
|
+
t.libs << 'lib'
|
12
|
+
t.libs << 'test'
|
13
|
+
t.pattern = 'test/**/*_test.rb'
|
14
|
+
t.verbose = true
|
15
|
+
end
|
16
|
+
|
17
|
+
desc 'Generate documentation for the abingo plugin.'
|
18
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
19
|
+
rdoc.rdoc_dir = 'rdoc'
|
20
|
+
rdoc.title = 'Abingo'
|
21
|
+
rdoc.options << '--line-numbers' << '--inline-source'
|
22
|
+
rdoc.rdoc_files.include('README')
|
23
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
24
|
+
end
|
data/abingo.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/abingo/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Glenn Gillen"]
|
6
|
+
gem.email = ["me@glenngillen.com"]
|
7
|
+
gem.description = %q{A split testing framework for Rails 3.x.x}
|
8
|
+
gem.summary = %q{The ABingo split testing framework for Rails 3.x.x from Patrick McKenzie}
|
9
|
+
gem.homepage = "https://github.com/glenngillen/abingo"
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = "abingo"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Abingo::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency "rails", "~> 3.0"
|
19
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/active_record'
|
3
|
+
class AbingoMigrationGenerator < Rails::Generators::Base
|
4
|
+
include Rails::Generators::Migration
|
5
|
+
|
6
|
+
source_root File.expand_path('../templates', __FILE__)
|
7
|
+
|
8
|
+
def self.next_migration_number(dirname) #:nodoc:
|
9
|
+
next_migration_number = current_migration_number(dirname) + 1
|
10
|
+
if ActiveRecord::Base.timestamped_migrations
|
11
|
+
[Time.now.utc.strftime("%Y%m%d%H%M%S"), "%.14d" % next_migration_number].max
|
12
|
+
else
|
13
|
+
"%.3d" % next_migration_number
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def version
|
18
|
+
Abingo.MAJOR_VERSION.gsub(".", "")
|
19
|
+
end
|
20
|
+
|
21
|
+
def copy_migration
|
22
|
+
migration_template 'abingo_migration.rb', "db/migrate/abingo_migration#{version}"
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
#Creates the two database tables, plus indexes, you'll need to use A/Bingo.
|
2
|
+
|
3
|
+
class AbingoMigration<%= version -%> < ActiveRecord::Migration
|
4
|
+
def self.up
|
5
|
+
create_table "experiments", :force => true do |t|
|
6
|
+
t.string "test_name"
|
7
|
+
t.string "status"
|
8
|
+
t.timestamps
|
9
|
+
end
|
10
|
+
|
11
|
+
add_index "experiments", "test_name"
|
12
|
+
#add_index "experiments", "created_on"
|
13
|
+
|
14
|
+
create_table "alternatives", :force => true do |t|
|
15
|
+
t.integer :experiment_id
|
16
|
+
t.string :content
|
17
|
+
t.string :lookup, :limit => 32
|
18
|
+
t.integer :weight, :default => 1
|
19
|
+
t.integer :participants, :default => 0
|
20
|
+
t.integer :conversions, :default => 0
|
21
|
+
end
|
22
|
+
|
23
|
+
add_index "alternatives", "experiment_id"
|
24
|
+
add_index "alternatives", "lookup" #Critical for speed, since we'll primarily be updating by that.
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.down
|
28
|
+
drop_table :experiments
|
29
|
+
drop_table :alternatives
|
30
|
+
end
|
31
|
+
end
|
data/lib/abingo.rb
ADDED
@@ -0,0 +1,315 @@
|
|
1
|
+
require "abingo/version"
|
2
|
+
require "#{File.dirname(__FILE__)}/abingo_sugar"
|
3
|
+
require "#{File.dirname(__FILE__)}/abingo_view_helper"
|
4
|
+
require "abingo/controller/dashboard"
|
5
|
+
require "abingo/rails/controller/dashboard"
|
6
|
+
require "abingo/alternative"
|
7
|
+
require "abingo/experiment"
|
8
|
+
require "#{File.dirname(__FILE__)}/../generators/abingo_migration/abingo_migration_generator.rb"
|
9
|
+
ActionController::Base.send :include, AbingoSugar
|
10
|
+
ActionView::Base.send :include, AbingoViewHelper
|
11
|
+
#This class is outside code's main interface into the ABingo A/B testing framework.
|
12
|
+
#Unless you're fiddling with implementation details, it is the only one you need worry about.
|
13
|
+
|
14
|
+
#Usage of ABingo, including practical hints, is covered at http://www.bingocardcreator.com/abingo
|
15
|
+
|
16
|
+
class Abingo
|
17
|
+
|
18
|
+
@@VERSION = "1.1.0"
|
19
|
+
@@MAJOR_VERSION = "1.1"
|
20
|
+
cattr_reader :VERSION
|
21
|
+
cattr_reader :MAJOR_VERSION
|
22
|
+
|
23
|
+
#Not strictly necessary, but eh, as long as I'm here.
|
24
|
+
cattr_accessor :salt
|
25
|
+
@@salt = "Not really necessary."
|
26
|
+
|
27
|
+
@@options ||= {}
|
28
|
+
cattr_accessor :options
|
29
|
+
|
30
|
+
#Defined options:
|
31
|
+
# :enable_specification => if true, allow params[test_name] to override the calculated value for a test.
|
32
|
+
# :enable_override_in_session => if true, allows session[test_name] to override the calculated value for a test.
|
33
|
+
# :expires_in => if not nil, passes expire_in to creation of per-user cache keys. Useful for Redis, to prevent expired sessions
|
34
|
+
# from running wild and consuming all of your memory.
|
35
|
+
# :count_humans_only => Count only participation and conversions from humans. Humans can be identified by calling Abingo.mark_human!
|
36
|
+
# This can be done in e.g. Javascript code, which bots will typically not execute. See FAQ for details.
|
37
|
+
# :expires_in_for_bots => if not nil, passes expire_in to creation of per-user cache keys, but only for bots.
|
38
|
+
# Only matters if :count_humans_only is on.
|
39
|
+
|
40
|
+
#ABingo stores whether a particular user has participated in a particular
|
41
|
+
#experiment yet, and if so whether they converted, in the cache.
|
42
|
+
#
|
43
|
+
#It is STRONGLY recommended that you use a MemcacheStore for this.
|
44
|
+
#If you'd like to persist this through a system restart or the like, you can
|
45
|
+
#look into memcachedb, which speaks the memcached protocol. From the perspective
|
46
|
+
#of Rails it is just another MemcachedStore.
|
47
|
+
#
|
48
|
+
#You can overwrite Abingo's cache instance, if you would like it to not share
|
49
|
+
#your generic Rails cache.
|
50
|
+
cattr_writer :cache
|
51
|
+
|
52
|
+
def self.cache
|
53
|
+
@@cache || Rails.cache
|
54
|
+
end
|
55
|
+
|
56
|
+
#This method gives a unique identity to a user. It can be absolutely anything
|
57
|
+
#you want, as long as it is consistent.
|
58
|
+
#
|
59
|
+
#We use the identity to determine, deterministically, which alternative a user sees.
|
60
|
+
#This means that if you use Abingo.identify_user on someone at login, they will
|
61
|
+
#always see the same alternative for a particular test which is past the login
|
62
|
+
#screen. For details and usage notes, see the docs.
|
63
|
+
def self.identity=(new_identity)
|
64
|
+
@@identity = new_identity.to_s
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.identity
|
68
|
+
@@identity ||= rand(10 ** 10).to_i.to_s
|
69
|
+
end
|
70
|
+
|
71
|
+
#A simple convenience method for doing an A/B test. Returns true or false.
|
72
|
+
#If you pass it a block, it will bind the choice to the variable given to the block.
|
73
|
+
def self.flip(test_name)
|
74
|
+
if block_given?
|
75
|
+
yield(self.test(test_name, [true, false]))
|
76
|
+
else
|
77
|
+
self.test(test_name, [true, false])
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
#This is the meat of A/Bingo.
|
82
|
+
#options accepts
|
83
|
+
# :multiple_participation (true or false)
|
84
|
+
# :conversion name of conversion to listen for (alias: conversion_name)
|
85
|
+
def self.test(test_name, alternatives, options = {})
|
86
|
+
|
87
|
+
short_circuit = Abingo.cache.read("Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_"))
|
88
|
+
unless short_circuit.nil?
|
89
|
+
return short_circuit #Test has been stopped, pick canonical alternative.
|
90
|
+
end
|
91
|
+
|
92
|
+
unless Abingo::Experiment.exists?(test_name)
|
93
|
+
lock_key = "Abingo::lock_for_creation(#{test_name.gsub(" ", "_")})"
|
94
|
+
creation_required = true
|
95
|
+
|
96
|
+
#this prevents (most) repeated creations of experiments in high concurrency environments.
|
97
|
+
if Abingo.cache.exist?(lock_key)
|
98
|
+
creation_required = false
|
99
|
+
while Abingo.cache.exist?(lock_key)
|
100
|
+
sleep(0.1)
|
101
|
+
end
|
102
|
+
creation_required = Abingo::Experiment.exists?(test_name)
|
103
|
+
end
|
104
|
+
|
105
|
+
if creation_required
|
106
|
+
Abingo.cache.write(lock_key, 1, :expires_in => 5.seconds)
|
107
|
+
conversion_name = options[:conversion] || options[:conversion_name]
|
108
|
+
Abingo::Experiment.start_experiment!(test_name, self.parse_alternatives(alternatives), conversion_name)
|
109
|
+
Abingo.cache.delete(lock_key)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
choice = self.find_alternative_for_user(test_name, alternatives)
|
114
|
+
participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
|
115
|
+
|
116
|
+
#Set this user to participate in this experiment, and increment participants count.
|
117
|
+
if options[:multiple_participation] || !(participating_tests.include?(test_name))
|
118
|
+
unless participating_tests.include?(test_name)
|
119
|
+
participating_tests = participating_tests + [test_name]
|
120
|
+
expires_in = Abingo.expires_in
|
121
|
+
if expires_in
|
122
|
+
Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests, {:expires_in => expires_in})
|
123
|
+
else
|
124
|
+
Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
#If we're only counting known humans, then postpone scoring participation until after we know the user is human.
|
128
|
+
if (!@@options[:count_humans_only] || Abingo.is_human?)
|
129
|
+
Abingo::Alternative.score_participation(test_name)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
if block_given?
|
134
|
+
yield(choice)
|
135
|
+
else
|
136
|
+
choice
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
|
141
|
+
#Scores conversions for tests.
|
142
|
+
#test_name_or_array supports three types of input:
|
143
|
+
#
|
144
|
+
#A conversion name: scores a conversion for any test the user is participating in which
|
145
|
+
# is listening to the specified conversion.
|
146
|
+
#
|
147
|
+
#A test name: scores a conversion for the named test if the user is participating in it.
|
148
|
+
#
|
149
|
+
#An array of either of the above: for each element of the array, process as above.
|
150
|
+
#
|
151
|
+
#nil: score a conversion for every test the u
|
152
|
+
def Abingo.bingo!(name = nil, options = {})
|
153
|
+
if name.kind_of? Array
|
154
|
+
name.map do |single_test|
|
155
|
+
self.bingo!(single_test, options)
|
156
|
+
end
|
157
|
+
else
|
158
|
+
if name.nil?
|
159
|
+
#Score all participating tests
|
160
|
+
participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
|
161
|
+
participating_tests.each do |participating_test|
|
162
|
+
self.bingo!(participating_test, options)
|
163
|
+
end
|
164
|
+
else #Could be a test name or conversion name.
|
165
|
+
conversion_name = name.gsub(" ", "_")
|
166
|
+
tests_listening_to_conversion = Abingo.cache.read("Abingo::tests_listening_to_conversion#{conversion_name}")
|
167
|
+
if tests_listening_to_conversion
|
168
|
+
if tests_listening_to_conversion.size > 1
|
169
|
+
tests_listening_to_conversion.map do |individual_test|
|
170
|
+
self.score_conversion!(individual_test.to_s)
|
171
|
+
end
|
172
|
+
elsif tests_listening_to_conversion.size == 1
|
173
|
+
test_name_str = tests_listening_to_conversion.first.to_s
|
174
|
+
self.score_conversion!(test_name_str)
|
175
|
+
end
|
176
|
+
else
|
177
|
+
#No tests listening for this conversion. Assume it is just a test name.
|
178
|
+
test_name_str = name.to_s
|
179
|
+
self.score_conversion!(test_name_str)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def self.participating_tests(only_current = true)
|
186
|
+
identity = Abingo.identity
|
187
|
+
participating_tests = Abingo.cache.read("Abingo::participating_tests::#{identity}") || []
|
188
|
+
tests_and_alternatives = participating_tests.inject({}) do |acc, test_name|
|
189
|
+
alternatives_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
|
190
|
+
alternatives = Abingo.cache.read(alternatives_key)
|
191
|
+
acc[test_name] = Abingo.find_alternative_for_user(test_name, alternatives)
|
192
|
+
acc
|
193
|
+
end
|
194
|
+
if (only_current)
|
195
|
+
tests_and_alternatives.reject! do |key, value|
|
196
|
+
self.cache.read("Abingo::Experiment::short_circuit(#{key})")
|
197
|
+
end
|
198
|
+
end
|
199
|
+
tests_and_alternatives
|
200
|
+
end
|
201
|
+
|
202
|
+
#Marks that this user is human.
|
203
|
+
def self.human!
|
204
|
+
Abingo.cache.fetch("Abingo::is_human(#{Abingo.identity})", {:expires_in => Abingo.expires_in(true)}) do
|
205
|
+
#Now that we know the user is human, score participation for all their tests. (Further participation will *not* be lazy evaluated.)
|
206
|
+
|
207
|
+
#Score all tests which have been deferred.
|
208
|
+
participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
|
209
|
+
|
210
|
+
#Refresh cache expiry for this user to match that of known humans.
|
211
|
+
if (@@options[:expires_in_for_bots] && !participating_tests.blank?)
|
212
|
+
Abingo.cache.write("Abingo::participating_tests::#{Abingo.identity}", participating_tests, {:expires_in => Abingo.expires_in(true)})
|
213
|
+
end
|
214
|
+
|
215
|
+
participating_tests.each do |test_name|
|
216
|
+
Alternative.score_participation(test_name)
|
217
|
+
if conversions = Abingo.cache.read("Abingo::conversions(#{Abingo.identity},#{test_name}")
|
218
|
+
conversions.times { Alternative.score_conversion(test_name) }
|
219
|
+
end
|
220
|
+
end
|
221
|
+
true #Marks this user as human in the cache.
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
225
|
+
protected
|
226
|
+
|
227
|
+
def self.is_human?
|
228
|
+
!!Abingo.cache.read("Abingo::is_human(#{Abingo.identity})")
|
229
|
+
end
|
230
|
+
|
231
|
+
#For programmer convenience, we allow you to specify what the alternatives for
|
232
|
+
#an experiment are in a few ways. Thus, we need to actually be able to handle
|
233
|
+
#all of them. We fire this parser very infrequently (once per test, typically)
|
234
|
+
#so it can be as complicated as we want.
|
235
|
+
# Integer => a number 1 through N
|
236
|
+
# Range => a number within the range
|
237
|
+
# Array => an element of the array.
|
238
|
+
# Hash => assumes a hash of something to int. We pick one of the
|
239
|
+
# somethings, weighted accorded to the ints provided. e.g.
|
240
|
+
# {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.
|
241
|
+
#
|
242
|
+
#Alternatives are always represented internally as an array.
|
243
|
+
def self.parse_alternatives(alternatives)
|
244
|
+
if alternatives.kind_of? Array
|
245
|
+
return alternatives
|
246
|
+
elsif alternatives.kind_of? Integer
|
247
|
+
return (1..alternatives).to_a
|
248
|
+
elsif alternatives.kind_of? Range
|
249
|
+
return alternatives.to_a
|
250
|
+
elsif alternatives.kind_of? Hash
|
251
|
+
alternatives_array = []
|
252
|
+
alternatives.each do |key, value|
|
253
|
+
if value.kind_of? Integer
|
254
|
+
alternatives_array += [key] * value
|
255
|
+
else
|
256
|
+
raise "You gave a hash with #{key} => #{value} as an element. The value must be an integral weight."
|
257
|
+
end
|
258
|
+
end
|
259
|
+
return alternatives_array
|
260
|
+
else
|
261
|
+
raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
def self.retrieve_alternatives(test_name, alternatives)
|
266
|
+
cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
|
267
|
+
alternative_array = self.cache.fetch(cache_key) do
|
268
|
+
self.parse_alternatives(alternatives)
|
269
|
+
end
|
270
|
+
alternative_array
|
271
|
+
end
|
272
|
+
|
273
|
+
def self.find_alternative_for_user(test_name, alternatives)
|
274
|
+
alternatives_array = retrieve_alternatives(test_name, alternatives)
|
275
|
+
alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
|
276
|
+
end
|
277
|
+
|
278
|
+
#Quickly determines what alternative to show a given user. Given a test name
|
279
|
+
#and their identity, we hash them together (which, for MD5, provably introduces
|
280
|
+
#enough entropy that we don't care) otherwise
|
281
|
+
def self.modulo_choice(test_name, choices_count)
|
282
|
+
Digest::MD5.hexdigest(Abingo.salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
|
283
|
+
end
|
284
|
+
|
285
|
+
def self.score_conversion!(test_name)
|
286
|
+
test_name.gsub!(" ", "_")
|
287
|
+
participating_tests = Abingo.cache.read("Abingo::participating_tests::#{Abingo.identity}") || []
|
288
|
+
if options[:assume_participation] || participating_tests.include?(test_name)
|
289
|
+
cache_key = "Abingo::conversions(#{Abingo.identity},#{test_name}"
|
290
|
+
if options[:multiple_conversions] || !Abingo.cache.read(cache_key)
|
291
|
+
if !options[:count_humans_only] || Abingo.is_human?
|
292
|
+
Abingo::Alternative.score_conversion(test_name)
|
293
|
+
end
|
294
|
+
|
295
|
+
if Abingo.cache.exist?(cache_key)
|
296
|
+
Abingo.cache.increment(cache_key)
|
297
|
+
else
|
298
|
+
Abingo.cache.write(cache_key, 1)
|
299
|
+
end
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
def self.expires_in(known_human = false)
|
305
|
+
expires_in = nil
|
306
|
+
if (@@options[:expires_in])
|
307
|
+
expires_in = @@options[:expires_in]
|
308
|
+
end
|
309
|
+
if (@@options[:count_humans_only] && @@options[:expires_in_for_bots] && !(known_human || Abingo.is_human?))
|
310
|
+
expires_in = @@options[:expires_in_for_bots]
|
311
|
+
end
|
312
|
+
expires_in
|
313
|
+
end
|
314
|
+
|
315
|
+
end
|