abingo 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1 @@
1
+ .svn
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in abingo.gemspec
4
+ gemspec
@@ -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!
@@ -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
@@ -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
@@ -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
@@ -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