abnormal 0.0.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,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