bananasplit 2.0.3

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,3 @@
1
+ .svn
2
+ abingo-test
3
+ pkg
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in abingo.gemspec
4
+ gemspec
5
+ gem "contest", :group => :test
6
+ gem "ruby-debug19", :group => :test
7
+ gem "pg", :group => :test
@@ -0,0 +1,102 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ bananasplit (2.0.3)
5
+ rails (~> 3.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ abstract (1.0.0)
11
+ actionmailer (3.0.20)
12
+ actionpack (= 3.0.20)
13
+ mail (~> 2.2.19)
14
+ actionpack (3.0.20)
15
+ activemodel (= 3.0.20)
16
+ activesupport (= 3.0.20)
17
+ builder (~> 2.1.2)
18
+ erubis (~> 2.6.6)
19
+ i18n (~> 0.5.0)
20
+ rack (~> 1.2.5)
21
+ rack-mount (~> 0.6.14)
22
+ rack-test (~> 0.5.7)
23
+ tzinfo (~> 0.3.23)
24
+ activemodel (3.0.20)
25
+ activesupport (= 3.0.20)
26
+ builder (~> 2.1.2)
27
+ i18n (~> 0.5.0)
28
+ activerecord (3.0.20)
29
+ activemodel (= 3.0.20)
30
+ activesupport (= 3.0.20)
31
+ arel (~> 2.0.10)
32
+ tzinfo (~> 0.3.23)
33
+ activeresource (3.0.20)
34
+ activemodel (= 3.0.20)
35
+ activesupport (= 3.0.20)
36
+ activesupport (3.0.20)
37
+ archive-tar-minitar (0.5.2)
38
+ arel (2.0.10)
39
+ builder (2.1.2)
40
+ columnize (0.3.6)
41
+ contest (0.1.3)
42
+ erubis (2.6.6)
43
+ abstract (>= 1.0.0)
44
+ i18n (0.5.0)
45
+ json (1.8.0)
46
+ linecache19 (0.5.12)
47
+ ruby_core_source (>= 0.1.4)
48
+ mail (2.2.19)
49
+ activesupport (>= 2.3.6)
50
+ i18n (>= 0.4.0)
51
+ mime-types (~> 1.16)
52
+ treetop (~> 1.4.8)
53
+ mime-types (1.23)
54
+ pg (0.14.1)
55
+ polyglot (0.3.3)
56
+ rack (1.2.8)
57
+ rack-mount (0.6.14)
58
+ rack (>= 1.0.0)
59
+ rack-test (0.5.7)
60
+ rack (>= 1.0)
61
+ rails (3.0.20)
62
+ actionmailer (= 3.0.20)
63
+ actionpack (= 3.0.20)
64
+ activerecord (= 3.0.20)
65
+ activeresource (= 3.0.20)
66
+ activesupport (= 3.0.20)
67
+ bundler (~> 1.0)
68
+ railties (= 3.0.20)
69
+ railties (3.0.20)
70
+ actionpack (= 3.0.20)
71
+ activesupport (= 3.0.20)
72
+ rake (>= 0.8.7)
73
+ rdoc (~> 3.4)
74
+ thor (~> 0.14.4)
75
+ rake (0.8.7)
76
+ rdoc (3.12.2)
77
+ json (~> 1.4)
78
+ ruby-debug-base19 (0.11.25)
79
+ columnize (>= 0.3.1)
80
+ linecache19 (>= 0.5.11)
81
+ ruby_core_source (>= 0.1.4)
82
+ ruby-debug19 (0.11.6)
83
+ columnize (>= 0.3.1)
84
+ linecache19 (>= 0.5.11)
85
+ ruby-debug-base19 (>= 0.11.19)
86
+ ruby_core_source (0.1.5)
87
+ archive-tar-minitar (>= 0.5.2)
88
+ thor (0.14.6)
89
+ treetop (1.4.14)
90
+ polyglot
91
+ polyglot (>= 0.3.1)
92
+ tzinfo (0.3.37)
93
+
94
+ PLATFORMS
95
+ ruby
96
+
97
+ DEPENDENCIES
98
+ bananasplit!
99
+ contest
100
+ pg
101
+ rake (= 0.8.7)
102
+ ruby-debug19
@@ -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.
@@ -0,0 +1,129 @@
1
+ # Banana Split (originally A/Bingo)
2
+
3
+ This is a fork of the A/Bingo project. I've specialized requirements and priorities that
4
+ aren't aligned with what Patrick wants with the original source. So I thought it best
5
+ to rename to avoid any confusion.
6
+
7
+ Rails A/B testing. One minute to install. One line to set up a new A/B test.
8
+ One line to track conversion.
9
+
10
+ For usage notes, see: http://www.bingocardcreator.com/abingo
11
+
12
+ Installation instructions are below usage examples.
13
+
14
+ Key default features:
15
+
16
+ * Conversions only tracked once per individual.
17
+ * Conversions only tracked if individual saw test.
18
+ * Same individual ALWAYS sees same alternative for same test.
19
+ * Syntax sugar. Specify alternatives as a range, array, hash of alternative to weighting, or just let it default to true or false.
20
+ * A simple z-test of statistical significance, with output so clear anyone in your organization can understand it.
21
+
22
+ ## Example: View
23
+
24
+ ``` erb
25
+ <% ab_test(@abingo_identity, "login_button", ["/images/button1.jpg", "/images/button2.jpg"]) do |button_file| %>
26
+ <%= img_tag(button_file, :alt => "Login!") %>
27
+ <% end %>
28
+ ```
29
+
30
+ ## Example: Controller
31
+
32
+ ``` ruby
33
+ def register_new_user
34
+ #See what level of free points maximizes users' decision to buy replacement points.
35
+ @starter_points = ab_test(@abingo_identity, "new_user_free_points", [100, 200, 300])
36
+ end
37
+ ```
38
+
39
+ ## Example: Controller
40
+
41
+ ``` ruby
42
+ def registration
43
+ if (ab_test(@abingo_identity, "send_welcome_email", :conversion => "purchase"))
44
+ #send the email, track to see if it later increases conversion to full version
45
+ end
46
+ end
47
+ ```
48
+
49
+ ## Example: Conversion tracking (in a controller!)
50
+
51
+ ``` ruby
52
+ def buy_new_points
53
+ #some business logic
54
+ @abingo_identity.bingo!("buy_new_points") #Either a conversion named with :conversion or a test name.
55
+ end
56
+ ```
57
+
58
+ ## Example: Conversion tracking (in a view)
59
+
60
+ ``` erb
61
+ Thanks for signing up, dude! <% @abingo_identity.bingo!("signup_page_redesign") >
62
+ ```
63
+
64
+ ## Example: Statistical Significance Testing
65
+
66
+ ``` irb
67
+ BananaSplit::Experiment.last.describe_result_in_words
68
+ => "The best alternative you have is: [0], which had 130 conversions from 5000 participants (2.60%).
69
+ The other alternative was [1], which had 1800 conversions from 100000 participants (1.80%).
70
+ This difference is 99.9% likely to be statistically significant, which means you can be extremely
71
+ confident that it is the result of your alternatives actually mattering, rather than being due to
72
+ random chance. However, this doesn't say anything about how much the first alternative is really
73
+ likely to be better by."
74
+ ```
75
+
76
+ ## Installation
77
+
78
+ 1) REQUIRED: You'll need to generate a DB migration to prepare two tables,
79
+ then migrate your database. (Note: slight edits required if you use the table names
80
+ "experiments" or "alternatives" at present.) Note: if you are upgrading to A/Bingo 1.0.0, you'll
81
+ want to do this again.
82
+
83
+ ``` shell
84
+ ruby script/generate abingo_migration
85
+ rake db:migrate
86
+ ```
87
+
88
+ 2) REQUIRED: You need to tell A/Bingo a user's identity so that it knows who is
89
+ who if they come back to a test. (The same identity will ALWAYS see the same
90
+ alternative for the same test.) How you do this is up to you -- I suggest integrating
91
+ with your login/account infrastructure. The simplest thing that can possibly work
92
+
93
+ ``` ruby
94
+ #Somewhere in application.rb
95
+ before_filter :set_abingo_identity
96
+
97
+ def set_abingo_identity
98
+ if (session[:abingo_identity])
99
+ @abingo_identity = Abingo.identify(session[:abingo_identity])
100
+ else
101
+ @abingo_identity = Abingo.identify
102
+ session[:abingo_identity] = @abingo_identity.identity
103
+ end
104
+ end
105
+ ```
106
+
107
+ 3) RECOMMENDED: A/Bingo makes HEAVY use of the cache to reduce load on the
108
+ database and share potentially long-lived "temporary" data, such as what alternative
109
+ a given visitor should be shown for a particular test. You SHOULD use a cache
110
+ which is shared across all Rails processes -- that probably means MemcachedStore or RedisStore.
111
+
112
+ You PROBABLY SHOULD use a persistent cache in case you need to restart your
113
+ machine. This is an amazingly good use case for MemcacheDB, so if you want to
114
+ try playing with that, Google it. (Sets up VERY easily on the newer Ubuntu distros.)
115
+
116
+ If you can't use a persistent cache, you're probably still OK if Memcached very
117
+ rarely needs to be restarted. If the cache gets flushed, you will double-count
118
+ entrants to a particular experiment and possibly double-count conversions, but
119
+ that may not be the worse thing in the world.
120
+
121
+ A/Bingo defaults to using the same cache store as Rails. If you want to change it
122
+
123
+ ``` ruby
124
+ #production.rb
125
+ BananaSplit.cache = ActiveSupport::Cache::MemCacheStore.new("cache.example.com:12345") #best if really memcacheDB
126
+ ```
127
+
128
+
129
+ 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,20 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/bananasplit/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/bananasplit"
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 = "bananasplit"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = BananaSplit::VERSION
17
+
18
+ gem.add_dependency "rails", "~> 3.0"
19
+ gem.add_development_dependency "rake", "0.8.7"
20
+ 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,325 @@
1
+ require_relative "bananasplit/version"
2
+ require_relative "bananasplit_sugar"
3
+ require_relative "bananasplit_view_helper"
4
+ require_relative "bananasplit/controller/dashboard"
5
+ require_relative "bananasplit/rails/controller/dashboard"
6
+ require_relative "bananasplit/alternative"
7
+ require_relative "bananasplit/experiment"
8
+ require_relative "../generators/bananasplit_migration/bananasplit_migration_generator.rb"
9
+ ActionController::Base.send :include, BananaSplitSugar
10
+ ActionView::Base.send :include, BananaSplitViewHelper
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 BananaSplit
17
+ cattr_accessor :salt
18
+ @@salt = "Not really necessary."
19
+
20
+ @@options ||= {}
21
+ cattr_accessor :options
22
+
23
+ attr_accessor :identity
24
+
25
+ #Defined options:
26
+ # :enable_specification => if true, allow params[test_name] to override the calculated value for a test.
27
+ # :enable_override_in_session => if true, allows session[test_name] to override the calculated value for a test.
28
+ # :expires_in => if not nil, passes expire_in to creation of per-user cache keys. Useful for Redis, to prevent expired sessions
29
+ # from running wild and consuming all of your memory.
30
+ # :count_humans_only => Count only participation and conversions from humans. Humans can be identified by calling @abingo.mark_human!
31
+ # This can be done in e.g. Javascript code, which bots will typically not execute. See FAQ for details.
32
+ # :expires_in_for_bots => if not nil, passes expire_in to creation of per-user cache keys, but only for bots.
33
+ # Only matters if :count_humans_only is on.
34
+
35
+ #ABingo stores whether a particular user has participated in a particular
36
+ #experiment yet, and if so whether they converted, in the cache.
37
+ #
38
+ #It is STRONGLY recommended that you use a MemcacheStore for this.
39
+ #If you'd like to persist this through a system restart or the like, you can
40
+ #look into memcachedb, which speaks the memcached protocol. From the perspective
41
+ #of Rails it is just another MemcachedStore.
42
+ #
43
+ #You can overwrite BananaSplit's cache instance, if you would like it to not share
44
+ #your generic Rails cache.
45
+
46
+ def self.cache
47
+ @cache || ::Rails.cache
48
+ end
49
+
50
+ def self.cache=(cache)
51
+ @cache = cache
52
+ end
53
+
54
+ def self.identity=(new_identity)
55
+ raise RuntimeError.new("Setting identity on the class level has been deprecated. Please create an instance via: @abingo = BananaSplit.identify('user-id')")
56
+ end
57
+
58
+ def self.generate_identity
59
+ rand(10 ** 10).to_i.to_s
60
+ end
61
+
62
+ #This method identifies a user and ensures they consistently see the same alternative.
63
+ #This means that if you use BananaSplit.identify on someone at login, they will
64
+ #always see the same alternative for a particular test which is past the login
65
+ #screen. For details and usage notes, see the docs.
66
+ def self.identify(identity = nil)
67
+ identity ||= generate_identity
68
+ new(identity)
69
+ end
70
+
71
+ def initialize(identity)
72
+ @identity = identity
73
+ super()
74
+ end
75
+
76
+ #A simple convenience method for doing an A/B test. Returns true or false.
77
+ #If you pass it a block, it will bind the choice to the variable given to the block.
78
+ def flip(test_name)
79
+ if block_given?
80
+ yield(self.test(test_name, [true, false]))
81
+ else
82
+ self.test(test_name, [true, false])
83
+ end
84
+ end
85
+
86
+ #This is the meat of A/Bingo.
87
+ #options accepts
88
+ # :multiple_participation (true or false)
89
+ # :conversion name of conversion to listen for (alias: conversion_name)
90
+ def test(test_name, alternatives, options = {})
91
+
92
+ short_circuit = BananaSplit.cache.read("BananaSplit::Experiment::short_circuit(#{test_name})".gsub(" ", "_"))
93
+ unless short_circuit.nil?
94
+ return short_circuit #Test has been stopped, pick canonical alternative.
95
+ end
96
+
97
+ unless BananaSplit::Experiment.exists?(test_name)
98
+ lock_key = "BananaSplit::lock_for_creation(#{test_name.gsub(" ", "_")})"
99
+ lock_id = SecureRandom.hex
100
+ #this prevents (most) repeated creations of experiments in high concurrency environments.
101
+ if BananaSplit.cache.exist?(lock_key)
102
+ wait_for_lock_release(lock_key)
103
+ else
104
+ BananaSplit.cache.write(lock_key, lock_id, :expires_in => 5.seconds)
105
+ sleep(0.1)
106
+ if BananaSplit.cache.read(lock_key) == lock_id
107
+ conversion_name = options[:conversion] || options[:conversion_name]
108
+ BananaSplit::Experiment.start_experiment!(test_name, BananaSplit.parse_alternatives(alternatives), conversion_name)
109
+ else
110
+ wait_for_lock_release(lock_key)
111
+ end
112
+ end
113
+ BananaSplit.cache.delete(lock_key)
114
+ end
115
+
116
+ choice = self.find_alternative_for_user(test_name, alternatives)
117
+ participating_tests = BananaSplit.cache.read("BananaSplit::participating_tests::#{self.identity}") || []
118
+
119
+ #Set this user to participate in this experiment, and increment participants count.
120
+ if options[:multiple_participation] || !(participating_tests.include?(test_name))
121
+ unless participating_tests.include?(test_name)
122
+ participating_tests = participating_tests + [test_name]
123
+ if self.expires_in
124
+ BananaSplit.cache.write("BananaSplit::participating_tests::#{self.identity}", participating_tests, {:expires_in => self.expires_in})
125
+ else
126
+ BananaSplit.cache.write("BananaSplit::participating_tests::#{self.identity}", participating_tests)
127
+ end
128
+ end
129
+ #If we're only counting known humans, then postpone scoring participation until after we know the user is human.
130
+ if (!@@options[:count_humans_only] || self.is_human?)
131
+ BananaSplit::Alternative.score_participation(test_name, choice)
132
+ end
133
+ end
134
+
135
+ if block_given?
136
+ yield(choice)
137
+ else
138
+ choice
139
+ end
140
+ end
141
+
142
+ def wait_for_lock_release(lock_key)
143
+ while BananaSplit.cache.exist?(lock_key)
144
+ sleep(0.1)
145
+ end
146
+ end
147
+
148
+ #Scores conversions for tests.
149
+ #test_name_or_array supports three types of input:
150
+ #
151
+ #A conversion name: scores a conversion for any test the user is participating in which
152
+ # is listening to the specified conversion.
153
+ #
154
+ #A test name: scores a conversion for the named test if the user is participating in it.
155
+ #
156
+ #An array of either of the above: for each element of the array, process as above.
157
+ #
158
+ #nil: score a conversion for every test the u
159
+ def bingo!(name = nil, options = {})
160
+ if name.kind_of? Array
161
+ name.map do |single_test|
162
+ self.bingo!(single_test, options)
163
+ end
164
+ else
165
+ if name.nil?
166
+ #Score all participating tests
167
+ participating_tests = BananaSplit.cache.read("BananaSplit::participating_tests::#{self.identity}") || []
168
+ participating_tests.each do |participating_test|
169
+ self.bingo!(participating_test, options)
170
+ end
171
+ else #Could be a test name or conversion name.
172
+ conversion_name = name.gsub(" ", "_")
173
+ tests_listening_to_conversion = BananaSplit.cache.read("BananaSplit::tests_listening_to_conversion#{conversion_name}")
174
+ if tests_listening_to_conversion
175
+ if tests_listening_to_conversion.size > 1
176
+ tests_listening_to_conversion.map do |individual_test|
177
+ self.score_conversion!(individual_test.to_s)
178
+ end
179
+ elsif tests_listening_to_conversion.size == 1
180
+ test_name_str = tests_listening_to_conversion.first.to_s
181
+ self.score_conversion!(test_name_str)
182
+ end
183
+ else
184
+ #No tests listening for this conversion. Assume it is just a test name.
185
+ test_name_str = name.to_s
186
+ self.score_conversion!(test_name_str)
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ def participating_tests(only_current = true)
193
+ participating_tests = BananaSplit.cache.read("BananaSplit::participating_tests::#{identity}") || []
194
+ tests_and_alternatives = participating_tests.inject({}) do |acc, test_name|
195
+ alternatives_key = "BananaSplit::Experiment::#{test_name}::alternatives".gsub(" ","_")
196
+ alternatives = BananaSplit.cache.read(alternatives_key)
197
+ acc[test_name] = find_alternative_for_user(test_name, alternatives)
198
+ acc
199
+ end
200
+ if (only_current)
201
+ tests_and_alternatives.reject! do |key, value|
202
+ BananaSplit.cache.read("BananaSplit::Experiment::short_circuit(#{key})")
203
+ end
204
+ end
205
+ tests_and_alternatives
206
+ end
207
+
208
+ #Marks that this user is human.
209
+ def human!
210
+ BananaSplit.cache.fetch("BananaSplit::is_human(#{self.identity})", {:expires_in => self.expires_in(true)}) do
211
+ #Now that we know the user is human, score participation for all their tests. (Further participation will *not* be lazy evaluated.)
212
+
213
+ #Score all tests which have been deferred.
214
+ participating_tests = BananaSplit.cache.read("BananaSplit::participating_tests::#{self.identity}") || []
215
+
216
+ #Refresh cache expiry for this user to match that of known humans.
217
+ if (@@options[:expires_in_for_bots] && !participating_tests.blank?)
218
+ BananaSplit.cache.write("BananaSplit::participating_tests::#{self.identity}", participating_tests, {:expires_in => self.expires_in(true)})
219
+ end
220
+
221
+ participating_tests.each do |test_name|
222
+ viewed_alternative = find_alternative_for_user(test_name,
223
+ BananaSplit::Experiment.alternatives_for_test(test_name))
224
+ Alternative.score_participation(test_name, viewed_alternative)
225
+ if conversions = BananaSplit.cache.read("BananaSplit::conversions(#{self.identity},#{test_name}")
226
+ conversions.times { Alternative.score_conversion(test_name, viewed_alternative) }
227
+ end
228
+ end
229
+ true #Marks this user as human in the cache.
230
+ end
231
+ end
232
+
233
+ def is_human?
234
+ !!BananaSplit.cache.read("BananaSplit::is_human(#{self.identity})")
235
+ end
236
+
237
+ protected
238
+
239
+ #For programmer convenience, we allow you to specify what the alternatives for
240
+ #an experiment are in a few ways. Thus, we need to actually be able to handle
241
+ #all of them. We fire this parser very infrequently (once per test, typically)
242
+ #so it can be as complicated as we want.
243
+ # Integer => a number 1 through N
244
+ # Range => a number within the range
245
+ # Array => an element of the array.
246
+ # Hash => assumes a hash of something to int. We pick one of the
247
+ # somethings, weighted accorded to the ints provided. e.g.
248
+ # {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.
249
+ #
250
+ #Alternatives are always represented internally as an array.
251
+ def self.parse_alternatives(alternatives)
252
+ if alternatives.kind_of? Array
253
+ return alternatives
254
+ elsif alternatives.kind_of? Integer
255
+ return (1..alternatives).to_a
256
+ elsif alternatives.kind_of? Range
257
+ return alternatives.to_a
258
+ elsif alternatives.kind_of? Hash
259
+ alternatives_array = []
260
+ alternatives.each do |key, value|
261
+ if value.kind_of? Integer
262
+ alternatives_array += [key] * value
263
+ else
264
+ raise "You gave a hash with #{key} => #{value} as an element. The value must be an integral weight."
265
+ end
266
+ end
267
+ return alternatives_array
268
+ else
269
+ raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
270
+ end
271
+ end
272
+
273
+ def self.retrieve_alternatives(test_name, alternatives)
274
+ cache_key = "BananaSplit::Experiment::#{test_name}::alternatives".gsub(" ","_")
275
+ alternative_array = BananaSplit.cache.fetch(cache_key) do
276
+ BananaSplit.parse_alternatives(alternatives)
277
+ end
278
+ alternative_array
279
+ end
280
+
281
+ def find_alternative_for_user(test_name, alternatives)
282
+ alternatives_array = BananaSplit.retrieve_alternatives(test_name, alternatives)
283
+ alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
284
+ end
285
+
286
+ #Quickly determines what alternative to show a given user. Given a test name
287
+ #and their identity, we hash them together (which, for MD5, provably introduces
288
+ #enough entropy that we don't care) otherwise
289
+ def modulo_choice(test_name, choices_count)
290
+ Digest::MD5.hexdigest(BananaSplit.salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
291
+ end
292
+
293
+ def score_conversion!(test_name)
294
+ test_name.gsub!(" ", "_")
295
+ participating_tests = BananaSplit.cache.read("BananaSplit::participating_tests::#{self.identity}") || []
296
+ if options[:assume_participation] || participating_tests.include?(test_name)
297
+ cache_key = "BananaSplit::conversions(#{self.identity},#{test_name}"
298
+ if options[:multiple_conversions] || !BananaSplit.cache.read(cache_key)
299
+ if !options[:count_humans_only] || is_human?
300
+ viewed_alternative = find_alternative_for_user(test_name,
301
+ BananaSplit::Experiment.alternatives_for_test(test_name))
302
+ BananaSplit::Alternative.score_conversion(test_name, viewed_alternative)
303
+ end
304
+
305
+ if BananaSplit.cache.exist?(cache_key)
306
+ BananaSplit.cache.increment(cache_key)
307
+ else
308
+ BananaSplit.cache.write(cache_key, 1)
309
+ end
310
+ end
311
+ end
312
+ end
313
+
314
+ def expires_in(known_human = false)
315
+ expires_in = nil
316
+ if (@@options[:expires_in])
317
+ expires_in = @@options[:expires_in]
318
+ end
319
+ if (@@options[:count_humans_only] && @@options[:expires_in_for_bots] && !(known_human || is_human?))
320
+ expires_in = @@options[:expires_in_for_bots]
321
+ end
322
+ expires_in
323
+ end
324
+
325
+ end