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.
@@ -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