bananasplit 2.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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