abnormal 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 @@
1
+ A/B testing for Ruby
@@ -0,0 +1,95 @@
1
+ require 'abnormal/version'
2
+
3
+ class Abnormal
4
+ def self.db; @@db; end
5
+ def self.db=(db)
6
+ @@db = db
7
+ end
8
+
9
+ def self.ab_test(identity, test_name, alternatives, conversions)
10
+ conversions = [conversions] unless conversions.is_a? Array
11
+
12
+ test_id = Digest::MD5.hexdigest(test_name)
13
+ db['tests'].update(
14
+ {:name => test_name, :_id => test_id},
15
+ {
16
+ :$set => {
17
+ :alternatives => alternatives,
18
+ },
19
+ :$addToSet => {
20
+ :conversions => {:$each => conversions}
21
+ }
22
+ },
23
+ :upsert => true
24
+ )
25
+
26
+ conversions.each do |conversion|
27
+ db['participations'].update(
28
+ {
29
+ :participant => identity,
30
+ :test_id => test_id,
31
+ :conversion => conversion
32
+ },
33
+ {
34
+ :$set => {:conversions => 0}
35
+ },
36
+ :upsert => true
37
+ )
38
+ end
39
+
40
+ chose_alternative(identity, test_name, alternatives)
41
+ end
42
+
43
+ def self.convert!(identity, conversion)
44
+ db['participations'].update(
45
+ {
46
+ :participant => identity,
47
+ :conversion => conversion
48
+ },
49
+ {
50
+ :$inc => {:conversions => 1}
51
+ },
52
+ :multi => true
53
+ )
54
+ end
55
+
56
+ def self.get_test(test_id)
57
+ db['tests'].find_one(:_id => test_id)
58
+ end
59
+
60
+ def self.tests
61
+ db['tests'].find.to_a
62
+ end
63
+
64
+ def self.get_participation(id, test_name, conversion)
65
+ db['participations'].find_one(
66
+ :participant => id,
67
+ :test_id => Digest::MD5.hexdigest(test_name),
68
+ :conversion => conversion
69
+ )
70
+ end
71
+
72
+ def self.participations
73
+ db['participations'].find.to_a
74
+ end
75
+
76
+ def self.chose_alternative(identity, test_name, alternatives)
77
+ alternatives_array = normalize_alternatives(alternatives)
78
+ index = Digest::MD5.hexdigest(test_name + identity).to_i(16) % alternatives_array.size
79
+ alternatives_array[index]
80
+ end
81
+
82
+ def self.normalize_alternatives(alternatives)
83
+ case alternatives
84
+ when Array
85
+ alternatives
86
+ when Hash
87
+ alternatives_array = []
88
+ idx = 0
89
+ alternatives.each{|k,v| alternatives_array.fill(k, idx, v); idx += v}
90
+ alternatives_array
91
+ when Range
92
+ alternatives.to_a
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,20 @@
1
+ require 'abnormal/version'
2
+
3
+ class Abnormal
4
+ def self.db; @@db; end
5
+ def self.db=(db)
6
+ @@db = db
7
+ end
8
+
9
+ def self.ab_test(identity, test_name, alternatives, conversions)
10
+ db['tests'].update({:name => test_name}, {:$set => {:alternatives => alternatives, :id => Digest::MD5.hexdigest(test_name)}}, :upsert => true)
11
+ end
12
+
13
+ def self.get_test(test_id)
14
+ db['tests'].find_one(:id => test_id)
15
+ end
16
+
17
+ def self.tests
18
+ db['tests'].find.to_a
19
+ end
20
+ end
@@ -0,0 +1,4 @@
1
+ class Abnormal
2
+ VERSION = '0.0.0'
3
+ MAJOR_VERSION = '0'
4
+ end
File without changes
@@ -0,0 +1,240 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'simplecov'
4
+ SimpleCov.start
5
+
6
+ $:.unshift File.expand_path('../../lib', __FILE__)
7
+ require 'abnormal'
8
+
9
+ Bundler.require(:development)
10
+
11
+ describe Abnormal do
12
+ before(:all) do
13
+ Abnormal.db = Mongo::Connection.new['abnormal_test']
14
+ end
15
+
16
+ before(:each) do
17
+ Abnormal.db['tests'].drop
18
+ Abnormal.db['participations'].drop
19
+ end
20
+
21
+ describe 'ab_test' do
22
+ describe 'the first call for a new test' do
23
+ it "adds that test to the test table" do
24
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
25
+ Abnormal.get_test(Digest::MD5.hexdigest('test'))['name'].should == 'test'
26
+ end
27
+ end
28
+
29
+ describe "multiple calls for the same test" do
30
+ it "doesn't add a duplicate test" do
31
+ Abnormal.ab_test('id1', 'test', [1, 2], 'conversion')
32
+ Abnormal.ab_test('id2', 'test', [1, 2], 'conversion')
33
+ Abnormal.should have(1).tests
34
+ end
35
+ end
36
+
37
+ describe "multiple calls for multiple tests" do
38
+ it "creates multiple tests" do
39
+ Abnormal.ab_test('id', 'test1', [1, 2], 'conversion')
40
+ Abnormal.ab_test('id', 'test2', [1, 2], 'conversion')
41
+ Abnormal.should have(2).tests
42
+ end
43
+ end
44
+
45
+ describe "a call with one conversion" do
46
+ it "sets that conversion on the test" do
47
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
48
+ Abnormal.get_test(Digest::MD5.hexdigest('test'))['conversions'].to_set.should == %w[conversion].to_set
49
+ end
50
+ end
51
+
52
+ describe "multiple calls with the same conversion" do
53
+ it "doesn't add a duplicate conversions" do
54
+ Abnormal.ab_test('id1', 'test', [1, 2], 'conversion')
55
+ Abnormal.ab_test('id2', 'test', [1, 2], 'conversion')
56
+ Abnormal.get_test(Digest::MD5.hexdigest('test'))['conversions'].should have(1).items
57
+ end
58
+ end
59
+
60
+ describe "a call with multiple conversions" do
61
+ it "sets all of the conversions" do
62
+ Abnormal.ab_test('id', 'test', [1, 2], %w[conversion1 conversion2])
63
+ Abnormal.get_test(Digest::MD5.hexdigest('test'))['conversions'].to_set.should == %w[conversion1 conversion2].to_set
64
+ end
65
+ end
66
+
67
+ describe "multiple calls with different conversions" do
68
+ it "sets all of the conversions" do
69
+ Abnormal.ab_test('id1', 'test', [1, 2], 'conversion1')
70
+ Abnormal.ab_test('id2', 'test', [1, 2], 'conversion2')
71
+ Abnormal.get_test(Digest::MD5.hexdigest('test'))['conversions'].to_set.should == %w[conversion1 conversion2].to_set
72
+ end
73
+ end
74
+
75
+ describe "the first call by an identity that has not participated in the test with the conversion" do
76
+ it "records in the participation" do
77
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
78
+ Abnormal.get_participation('id', 'test', 'conversion').should be
79
+ end
80
+
81
+ it "has 0 conversions" do
82
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
83
+ Abnormal.get_participation('id', 'test', 'conversion')['conversions'].should == 0
84
+ end
85
+ end
86
+
87
+ describe "subsequent calls by an identity that has participated in the test with the conversion" do
88
+ it "records in the participation" do
89
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
90
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
91
+ Abnormal.should have(1).participations
92
+ end
93
+ end
94
+
95
+ describe "a call with multiple conversions" do
96
+ it "makes multiple participations" do
97
+ Abnormal.ab_test('id1', 'test', [1, 2], %w[conversion1 conversion2])
98
+ Abnormal.should have(2).participations
99
+ end
100
+ end
101
+
102
+ describe "different calls by different identities" do
103
+ it "makes different participations" do
104
+ Abnormal.ab_test('id1', 'test', [1, 2], 'conversion')
105
+ Abnormal.ab_test('id2', 'test', [1, 2], 'conversion')
106
+ Abnormal.should have(2).participations
107
+ end
108
+ end
109
+
110
+ describe "different calls for different tests" do
111
+ it "makes different participations" do
112
+ Abnormal.ab_test('id', 'test1', [1, 2], 'conversion')
113
+ Abnormal.ab_test('id', 'test2', [1, 2], 'conversion')
114
+ Abnormal.should have(2).participations
115
+ end
116
+ end
117
+
118
+ describe "different calls with different conversions" do
119
+ it "makes different participations" do
120
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion1')
121
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion2')
122
+ Abnormal.should have(2).participations
123
+ end
124
+ end
125
+
126
+ it "returns the correct alternative" do
127
+ Abnormal.stub(:chose_alternative){ 3 }
128
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion').should == 3
129
+ end
130
+ end
131
+
132
+ # I don't like any of these... is there a better way to do this?
133
+ describe "choose_alternative" do
134
+ it "returns the same result for the same user and test" do
135
+ alt1 = Abnormal.chose_alternative('id', 'test', [1, 2])
136
+ alt2 = Abnormal.chose_alternative('id', 'test', [1, 2])
137
+ alt1.should == alt2
138
+ end
139
+
140
+ it "returns different results for different users" do
141
+ alt1 = Abnormal.chose_alternative('id1', 'test', [1, 2])
142
+ alt2 = Abnormal.chose_alternative('id2', 'test', [1, 2])
143
+ alt1.should_not == alt2
144
+ end
145
+
146
+ it "returns different results for different tests" do
147
+ alt1 = Abnormal.chose_alternative('id', 'test_1', [1, 2])
148
+ alt2 = Abnormal.chose_alternative('id', 'test2', [1, 2])
149
+ alt1.should_not == alt2
150
+ end
151
+
152
+ it "returns all possible alternatives" do
153
+ alternatives = [1, 2]
154
+ actual_alternatives = (1..10).map{|i| Abnormal.chose_alternative("id#{i}", 'test', alternatives) }
155
+
156
+ actual_alternatives.to_set.should == alternatives.to_set
157
+ end
158
+
159
+ it "uses normalize_alternatives" do
160
+ Abnormal.stub(:normalize_alternatives){ [3] }
161
+ Abnormal.chose_alternative('id', 'test1', [1, 2]).should == 3
162
+ end
163
+ end
164
+
165
+ describe "convert!" do
166
+ describe "with an identity/conversion pair that does not exist" do
167
+ it "does nothing" do
168
+ Abnormal.convert!('id', 'conversion')
169
+ Abnormal.should have(0).participations
170
+ end
171
+
172
+ it "does not apply to future participations" do
173
+ identity = 'id'
174
+ test_name = 'test'
175
+ conversion = 'conversion'
176
+
177
+ Abnormal.convert!(identity, conversion)
178
+ Abnormal.ab_test(identity, test_name, [1, 2], conversion)
179
+ Abnormal.get_participation(identity, test_name, conversion)['conversions'].should == 0
180
+ end
181
+ end
182
+
183
+ describe "with an identity/conversion pair that does exist" do
184
+ it "records the conversions" do
185
+ identity = 'id'
186
+ test_name = 'test'
187
+ conversion = 'conversion'
188
+
189
+ Abnormal.ab_test(identity, test_name, [1, 2], conversion)
190
+ Abnormal.convert!(identity, conversion)
191
+ Abnormal.get_participation(identity, test_name, conversion)['conversions'].should == 1
192
+ end
193
+ end
194
+
195
+ describe "an identity participating in multiple tests with the conversions" do
196
+ it "records the conversion for the each test" do
197
+ identity = 'id'
198
+ conversion = 'conversion'
199
+
200
+ Abnormal.ab_test(identity, 'test1', [1, 2], conversion)
201
+ Abnormal.ab_test(identity, 'test2', [1, 2], conversion)
202
+ Abnormal.convert!(identity, conversion)
203
+ Abnormal.get_participation(identity, 'test1', conversion)['conversions'].should == 1
204
+ Abnormal.get_participation(identity, 'test2', conversion)['conversions'].should == 1
205
+ end
206
+ end
207
+
208
+ describe "an identity participating in multiple tests, not all of which have the conversion" do
209
+ it "only records a conversion for the test listening to that conversion" do
210
+ identity = 'id'
211
+
212
+ Abnormal.ab_test(identity, 'test1', [1, 2], 'conversion1')
213
+ Abnormal.ab_test(identity, 'test2', [1, 2], 'conversion2')
214
+ Abnormal.convert!(identity, 'conversion1')
215
+ Abnormal.get_participation(identity, 'test1', 'conversion1')['conversions'].should == 1
216
+ Abnormal.get_participation(identity, 'test2', 'conversion2')['conversions'].should == 0
217
+ end
218
+ end
219
+ end
220
+
221
+ describe "normalize_alternatives" do
222
+ describe "given an array" do
223
+ it "returns the array" do
224
+ Abnormal.normalize_alternatives([1, 2, 7]).should == [1, 2, 7]
225
+ end
226
+ end
227
+
228
+ describe "given a hash" do
229
+ it "expands the hash into an array" do
230
+ Abnormal.normalize_alternatives({:a => 1, :b => 9}).should == [:a, :b, :b, :b, :b, :b, :b, :b, :b, :b]
231
+ end
232
+ end
233
+
234
+ describe "given a range" do
235
+ it "converts the range into a hash" do
236
+ Abnormal.normalize_alternatives(1..10).should == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,77 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'simplecov'
4
+ SimpleCov.start
5
+
6
+ $:.unshift File.expand_path('../../lib', __FILE__)
7
+ require 'abnormal'
8
+
9
+ Bundler.require(:development)
10
+
11
+ describe Abnormal do
12
+ before(:all) do
13
+ Abnormal.db = Mongo::Connection.new['abnormal_test']
14
+ end
15
+
16
+ before(:each) do
17
+ Abnormal.db['tests'].drop
18
+ end
19
+
20
+ describe 'ab_test' do
21
+ describe 'the first call for a new test' do
22
+ it "adds that test to the test table" do
23
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
24
+ Abnormal.get_test(Digest::MD5.hexdigest('test'))['name'].should == 'test'
25
+ end
26
+ end
27
+
28
+ describe "multiple calls for the same test" do
29
+ it "doesn't add a duplicate test" do
30
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
31
+ Abnormal.ab_test('id2', 'test', [1, 2], 'conversion')
32
+ Abnormal.should have(1).tests
33
+ end
34
+ end
35
+
36
+ describe "multiple calls for multiple tests" do
37
+ it "should create multiple tests" do
38
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion')
39
+ Abnormal.ab_test('id', 'test2', [1, 2], 'conversion')
40
+ Abnormal.should have(2).tests
41
+ end
42
+ end
43
+
44
+ it "returns the correct alternative" do
45
+ Abnormal.stub(:chose_alternative){ 3 }
46
+ Abnormal.ab_test('id', 'test', [1, 2], 'conversion').should == 3
47
+ end
48
+ end
49
+
50
+ # I don't like any of these... is there a better way to do this?
51
+ describe "choose_alternative" do
52
+ it "returns the same result for the same user and test" do
53
+ alt1 = Abnormal.chose_alternative('id', 'test', [1, 2])
54
+ alt2 = Abnormal.chose_alternative('id', 'test', [1, 2])
55
+ alt1.should == alt2
56
+ end
57
+
58
+ it "returns different results for different users" do
59
+ alt1 = Abnormal.chose_alternative('id1', 'test', [1, 2])
60
+ alt2 = Abnormal.chose_alternative('id2', 'test', [1, 2])
61
+ alt1.should_not == alt2
62
+ end
63
+
64
+ it "returns different results for different tests" do
65
+ alt1 = Abnormal.chose_alternative('id', 'test_1', [1, 2])
66
+ alt2 = Abnormal.chose_alternative('id', 'test2', [1, 2])
67
+ alt1.should_not == alt2
68
+ end
69
+
70
+ it "returns all possible alternatives" do
71
+ alternatives = [1, 2]
72
+ actual_alternatives = (1..10).map{|i| Abnormal.chose_alternative("id#{i}", 'test', alternatives) }
73
+
74
+ actual_alternatives.to_set.should == alternatives.to_set
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,13 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'test/unit'
4
+
5
+ $:.unshift File.expand_path('../../lib', __FILE__)
6
+ require 'abnormal'
7
+
8
+ Bundler.require(:development)
9
+
10
+ class TestAbnormal < Test::Unit::TestCase
11
+ def setup
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'test/unit'
4
+
5
+ $:.unshift File.expand_path('../../lib', __FILE__)
6
+ require 'abnormal'
7
+
8
+ Bundler.require(:development)
9
+
10
+ class TestAbnormal < Test::Unit::TestCase
11
+ def setup
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: abnormal
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.0.0
6
+ platform: ruby
7
+ authors:
8
+ - Michael Fairley
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2011-08-28 00:00:00 -07:00
14
+ default_executable:
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: mongo
18
+ prerelease: false
19
+ requirement: &id001 !ruby/object:Gem::Requirement
20
+ none: false
21
+ requirements:
22
+ - - ">="
23
+ - !ruby/object:Gem::Version
24
+ version: "0"
25
+ type: :runtime
26
+ version_requirements: *id001
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ prerelease: false
30
+ requirement: &id002 !ruby/object:Gem::Requirement
31
+ none: false
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: "0"
36
+ type: :development
37
+ version_requirements: *id002
38
+ - !ruby/object:Gem::Dependency
39
+ name: rspec
40
+ prerelease: false
41
+ requirement: &id003 !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: "0"
47
+ type: :development
48
+ version_requirements: *id003
49
+ description:
50
+ email:
51
+ - michaelfairley@gmail.com
52
+ executables: []
53
+
54
+ extensions: []
55
+
56
+ extra_rdoc_files: []
57
+
58
+ files:
59
+ - lib/abnormal/version.rb
60
+ - lib/abnormal/version.rb~
61
+ - lib/abnormal.rb
62
+ - lib/abnormal.rb~
63
+ - test/test_abnormal.rb
64
+ - test/test_abnormal.rb~
65
+ - spec/abnormal_spec.rb
66
+ - spec/abnormal_spec.rb~
67
+ - MIT-LICENSE
68
+ - README.md
69
+ has_rdoc: true
70
+ homepage: http://github.com/michaelfairley/abnormal
71
+ licenses:
72
+ - MIT
73
+ post_install_message:
74
+ rdoc_options: []
75
+
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ none: false
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: "0"
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ none: false
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: "0"
90
+ requirements: []
91
+
92
+ rubyforge_project:
93
+ rubygems_version: 1.6.2
94
+ signing_key:
95
+ specification_version: 3
96
+ summary: Ruby A/B testing
97
+ test_files:
98
+ - test/test_abnormal.rb
99
+ - spec/abnormal_spec.rb