sinmetrics 0.0.7 → 0.0.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -42,40 +42,29 @@ module Sinatra
42
42
  #A simple convenience method for doing an A/B test. Returns true or false.
43
43
  #If you pass it a block, it will bind the choice to the variable given to the block.
44
44
  def flip(test_name)
45
+ choice = test(test_name, [true, false])
45
46
  if block_given?
46
- yield(test(test_name, [true, false]))
47
+ yield choice
47
48
  else
48
- test(test_name, [true, false])
49
+ choice
49
50
  end
50
51
  end
51
52
 
52
53
  #This is the meat of A/Bingo.
53
54
  #options accepts
54
55
  # :multiple_participation (true or false)
55
- # :conversion name of conversion to listen for (alias: conversion_name)
56
+ # :no_particiation (true or false)
56
57
  def test(test_name, alternatives, options = {})
57
- short_circuit = cache.read("Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_"))
58
- unless short_circuit.nil?
59
- return short_circuit #Test has been stopped, pick canonical alternative.
58
+ experiment = Experiment.get(test_name)
59
+ experiment ||= Experiment.start_experiment!(self, test_name, parse_alternatives(alternatives))
60
+
61
+ # Test has been stopped, pick canonical alternative.
62
+ unless experiment.short_circuit.nil?
63
+ return experiment.short_circuit
60
64
  end
61
65
 
62
- unless Experiment.exists?(self, test_name)
63
- conversion_name = options[:conversion] || options[:conversion_name]
64
- Experiment.start_experiment!(self, test_name, parse_alternatives(alternatives), conversion_name)
65
- end
66
-
67
66
  choice = find_alternative_for_user(test_name, alternatives)
68
- participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []
69
-
70
- #Set this user to participate in this experiment, and increment participants count.
71
- if options[:multiple_participation] || !(participating_tests.include?(test_name))
72
- participating_tests = participating_tests.dup if participating_tests.frozen?
73
- unless participating_tests.include?(test_name)
74
- participating_tests << test_name
75
- cache.write("Abingo::participating_tests::#{identity}", participating_tests)
76
- end
77
- Alternative.score_participation(self, test_name)
78
- end
67
+ score_participation!(test_name, options) unless options[:no_participation]
79
68
 
80
69
  if block_given?
81
70
  yield(choice)
@@ -87,9 +76,6 @@ module Sinatra
87
76
  #Scores conversions for tests.
88
77
  #test_name_or_array supports three types of input:
89
78
  #
90
- #A conversion name: scores a conversion for any test the user is participating in which
91
- # is listening to the specified conversion.
92
- #
93
79
  #A test name: scores a conversion for the named test if the user is participating in it.
94
80
  #
95
81
  #An array of either of the above: for each element of the array, process as above.
@@ -107,23 +93,8 @@ module Sinatra
107
93
  participating_tests.each do |participating_test|
108
94
  self.bingo!(participating_test, options)
109
95
  end
110
- else #Could be a test name or conversion name.
111
- conversion_name = name.gsub(" ", "_")
112
- tests_listening_to_conversion = cache.read("Abingo::tests_listening_to_conversion#{conversion_name}")
113
- if tests_listening_to_conversion
114
- if tests_listening_to_conversion.size > 1
115
- tests_listening_to_conversion.map do |individual_test|
116
- self.score_conversion!(individual_test.to_s, options)
117
- end
118
- elsif tests_listening_to_conversion.size == 1
119
- test_name_str = tests_listening_to_conversion.first.to_s
120
- self.score_conversion!(test_name_str, options)
121
- end
122
- else
123
- #No tests listening for this conversion. Assume it is just a test name.
124
- test_name_str = name.to_s
125
- self.score_conversion!(test_name_str, options)
126
- end
96
+ else
97
+ self.score_conversion!(name.to_s, options)
127
98
  end
128
99
  end
129
100
  end
@@ -164,33 +135,40 @@ module Sinatra
164
135
  end
165
136
  end
166
137
 
167
- def retrieve_alternatives(test_name, alternatives)
138
+ def find_alternative_for_user(test_name, alternatives)
168
139
  cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
169
140
  alternative_array = self.cache.fetch(cache_key) do
170
141
  self.parse_alternatives(alternatives)
171
142
  end
172
- alternative_array
173
- end
174
143
 
175
- def find_alternative_for_user(test_name, alternatives)
176
- alternatives_array = retrieve_alternatives(test_name, alternatives)
177
- alternatives_array[self.modulo_choice(test_name, alternatives_array.size)]
144
+ #Quickly determines what alternative to show a given user. Given a test name
145
+ #and their identity, we hash them together (which, for MD5, provably introduces
146
+ #enough entropy that we don't care) otherwise
147
+ choice = Digest::MD5.hexdigest(salt.to_s + test_name + self.identity.to_s).to_i(16) % alternative_array.size
148
+ alternative_array[choice]
178
149
  end
179
-
180
- #Quickly determines what alternative to show a given user. Given a test name
181
- #and their identity, we hash them together (which, for MD5, provably introduces
182
- #enough entropy that we don't care) otherwise
183
- def modulo_choice(test_name, choices_count)
184
- Digest::MD5.hexdigest(salt.to_s + test_name + self.identity.to_s).to_i(16) % choices_count
150
+
151
+ def alternatives_for_test(test_name)
152
+ cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
153
+ self.cache.fetch(cache_key) do
154
+ Experiment.get(test_name).alternatives.map do |alt|
155
+ if alt.weight > 1
156
+ [alt.content] * alt.weight
157
+ else
158
+ alt.content
159
+ end
160
+ end.flatten
161
+ end
185
162
  end
186
163
 
187
164
  def score_conversion!(test_name, options = {})
188
- test_name.gsub!(" ", "_")
189
165
  participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []
190
166
  if options[:assume_participation] || participating_tests.include?(test_name)
191
167
  cache_key = "Abingo::conversions(#{identity},#{test_name}"
192
168
  if options[:multiple_conversions] || !cache.read(cache_key)
193
- Alternative.score_conversion(self, test_name)
169
+ viewed_alternative = find_alternative_for_user(test_name, alternatives_for_test(test_name))
170
+ lookup = calculate_alternative_lookup(test_name, viewed_alternative)
171
+ Alternative.all(:lookup => lookup).adjust!(:conversions => 1)
194
172
  if cache.exist?(cache_key)
195
173
  cache.increment(cache_key)
196
174
  else
@@ -200,6 +178,23 @@ module Sinatra
200
178
  end
201
179
  end
202
180
 
181
+ def score_participation!(test_name, options = {})
182
+ participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []
183
+
184
+ #Set this user to participate in this experiment, and increment participants count.
185
+ if options[:multiple_participation] || !(participating_tests.include?(test_name))
186
+ participating_tests = participating_tests.dup if participating_tests.frozen?
187
+ unless participating_tests.include?(test_name)
188
+ participating_tests << test_name
189
+ cache.write("Abingo::participating_tests::#{identity}", participating_tests)
190
+ end
191
+
192
+ choice = find_alternative_for_user(test_name, alternatives_for_test(test_name))
193
+ lookup = calculate_alternative_lookup(test_name, choice)
194
+ Alternative.all(:lookup => lookup).adjust!(:participants => 1)
195
+ end
196
+ end
197
+
203
198
  def end_experiment(alternative_id)
204
199
  alternative = Alternative.get(alternative_id)
205
200
  experiment = alternative.experiment
@@ -207,6 +202,10 @@ module Sinatra
207
202
  experiment.end_experiment!(self, alternative.content)
208
203
  end
209
204
  end
205
+
206
+ def calculate_alternative_lookup(test_name, alternative_name)
207
+ Digest::MD5.hexdigest(self.salt + test_name + alternative_name.to_s)
208
+ end
210
209
  end
211
210
 
212
211
  module AbingoObject::ConversionRate
@@ -10,21 +10,4 @@ class Abingo::Alternative
10
10
  property :conversions, Integer, :default => 0
11
11
 
12
12
  belongs_to :experiment
13
-
14
- def self.calculate_lookup(abingo, test_name, alternative_name)
15
- Digest::MD5.hexdigest(abingo.salt + test_name + alternative_name.to_s)
16
- end
17
-
18
- def self.score_conversion(abingo, test_name)
19
- viewed_alternative = abingo.find_alternative_for_user(test_name,
20
- Abingo::Experiment.alternatives_for_test(abingo, test_name))
21
- all(:lookup => self.calculate_lookup(abingo, test_name, viewed_alternative)).adjust!(:conversions => 1)
22
- end
23
-
24
- def self.score_participation(abingo, test_name)
25
- viewed_alternative = abingo.find_alternative_for_user(test_name,
26
- Abingo::Experiment.alternatives_for_test(abingo, test_name))
27
- all(:lookup => self.calculate_lookup(abingo, test_name, viewed_alternative)).adjust!(:participants => 1)
28
- end
29
-
30
13
  end
@@ -5,6 +5,7 @@ class Abingo::Experiment
5
5
 
6
6
  property :test_name, String, :key => true
7
7
  property :status, String
8
+ property :short_circuit, String
8
9
 
9
10
  has n, :alternatives, "Alternative"
10
11
 
@@ -37,33 +38,7 @@ class Abingo::Experiment
37
38
  end
38
39
  end
39
40
 
40
- def self.exists?(abingo, test_name)
41
- cache_key = "Abingo::Experiment::exists(#{test_name})".gsub(" ", "_")
42
- ret = abingo.cache.fetch(cache_key) do
43
- count = Abingo::Experiment.count(:conditions => {:test_name => test_name})
44
- count > 0 ? count : nil
45
- end
46
- (!ret.nil?)
47
- end
48
-
49
- def self.alternatives_for_test(abingo, test_name)
50
- cache_key = "Abingo::#{test_name}::alternatives".gsub(" ","_")
51
- abingo.cache.fetch(cache_key) do
52
- experiment = Abingo::Experiment.first(:test_name => test_name)
53
- alternatives_array = abingo.cache.fetch(cache_key) do
54
- tmp_array = experiment.alternatives.map do |alt|
55
- [alt.content, alt.weight]
56
- end
57
- tmp_hash = tmp_array.inject({}) {|hash, couplet| hash[couplet[0]] = couplet[1]; hash}
58
- abingo.parse_alternatives(tmp_hash)
59
- end
60
- alternatives_array
61
- end
62
- end
63
-
64
- def self.start_experiment!(abingo, test_name, alternatives_array, conversion_name = nil)
65
- conversion_name ||= test_name
66
- conversion_name.gsub!(" ", "_")
41
+ def self.start_experiment!(abingo, test_name, alternatives_array)
67
42
  cloned_alternatives_array = alternatives_array.clone
68
43
  Abingo::Experiment.transaction do |txn|
69
44
  experiment = Abingo::Experiment.first_or_create(:test_name => test_name)
@@ -72,32 +47,22 @@ class Abingo::Experiment
72
47
  alt = cloned_alternatives_array[0]
73
48
  weight = cloned_alternatives_array.size - (cloned_alternatives_array - [alt]).size
74
49
  experiment.alternatives << Abingo::Alternative.new(:content => alt, :weight => weight,
75
- :lookup => Abingo::Alternative.calculate_lookup(abingo, test_name, alt))
50
+ :lookup => abingo.calculate_alternative_lookup(test_name, alt))
76
51
  cloned_alternatives_array -= [alt]
77
52
  end
78
53
  experiment.status = "Live"
79
54
  experiment.save
80
- abingo.cache.write("Abingo::Experiment::exists(#{test_name})".gsub(" ", "_"), 1)
81
-
82
- #This might have issues in very, very high concurrency environments...
83
-
84
- tests_listening_to_conversion = abingo.cache.read("Abingo::tests_listening_to_conversion#{conversion_name}") || []
85
- tests_listening_to_conversion = tests_listening_to_conversion.dup if tests_listening_to_conversion.frozen?
86
- tests_listening_to_conversion << test_name unless tests_listening_to_conversion.include? test_name
87
- abingo.cache.write("Abingo::tests_listening_to_conversion#{conversion_name}", tests_listening_to_conversion)
88
55
  experiment
89
56
  end
90
57
  end
91
58
 
92
- def end_experiment!(abingo, final_alternative, conversion_name = nil)
93
- conversion_name ||= test_name
59
+ def end_experiment!(abingo, final_alternative)
94
60
  Abingo::Experiment.transaction do
95
61
  alternatives.each do |alternative|
96
62
  alternative.lookup = "Experiment completed. #{alternative.id}"
97
63
  alternative.save!
98
64
  end
99
- update(:status => "Finished")
100
- abingo.cache.write("Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_"), final_alternative)
65
+ update(:status => "Finished", :short_circuit => final_alternative)
101
66
  end
102
67
  end
103
68
 
@@ -36,6 +36,13 @@ describe 'Abingo Specs' do
36
36
  @abingo.parse_alternatives(2..5).should_not include(1)
37
37
  end
38
38
 
39
+ it "should parse weighted alternatives" do
40
+ hash = { 'a' => 2, 'b' => 3, 'c' => 1}
41
+ array = %w{a a a b b c}
42
+ @abingo.parse_alternatives(array).should == array
43
+ end
44
+
45
+
39
46
  it "should create experiments" do
40
47
  Abingo::Experiment.count.should == 0
41
48
  Abingo::Alternative.count.should == 0
@@ -46,12 +53,6 @@ describe 'Abingo Specs' do
46
53
  alternatives.should include(alternative_selected)
47
54
  end
48
55
 
49
- it "should be able to call exists" do
50
- @abingo.test("exist words right", %w{does does_not})
51
- Abingo::Experiment.exists?(@abingo, "exist words right").should be_true
52
- Abingo::Experiment.exists?(@abingo, "other words right").should_not be_true
53
- end
54
-
55
56
  it "should pick alternatives consistently" do
56
57
  alternative_picked = @abingo.test("consistency_test", 1..100)
57
58
  100.times do
@@ -76,8 +77,8 @@ describe 'Abingo Specs' do
76
77
  test_name = "participants_counted_test"
77
78
  alternative = @abingo.test(test_name, %w{a b c})
78
79
 
79
- ex = Abingo::Experiment.first(:test_name => test_name)
80
- lookup = Abingo::Alternative.calculate_lookup(@abingo, test_name, alternative)
80
+ ex = Abingo::Experiment.get(test_name)
81
+ lookup = @abingo.calculate_alternative_lookup(test_name, alternative)
81
82
  chosen_alt = Abingo::Alternative.first(:lookup => lookup)
82
83
  ex.participants.should == 1
83
84
  chosen_alt.participants.should == 1
@@ -87,8 +88,8 @@ describe 'Abingo Specs' do
87
88
  test_name = "conversion_test_by_name"
88
89
  alternative = @abingo.test(test_name, %w{a b c})
89
90
  @abingo.bingo!(test_name)
90
- ex = Abingo::Experiment.first(:test_name => test_name)
91
- lookup = Abingo::Alternative.calculate_lookup(@abingo, test_name, alternative)
91
+ ex = Abingo::Experiment.get(test_name)
92
+ lookup = @abingo.calculate_alternative_lookup(test_name, alternative)
92
93
  chosen_alt = Abingo::Alternative.first(:lookup => lookup)
93
94
  ex.conversions.should == 1
94
95
  chosen_alt.conversions.should == 1
@@ -97,40 +98,25 @@ describe 'Abingo Specs' do
97
98
 
98
99
  #Should still only have one because this conversion should not be double counted.
99
100
  #We haven't specified that in the test options.
100
- ex = Abingo::Experiment.first(:test_name => test_name)
101
+ ex = Abingo::Experiment.get(test_name)
101
102
  ex.conversions.should == 1
102
103
  end
103
104
 
104
105
  it "should know the best alternative" do
105
106
  test_name = "conversion_test_by_name"
106
- alternative = @abingo.test(test_name, %w{a b c})
107
+ alternative = @abingo.test(test_name, {'a' => 3, 'b' => 2, 'c' => 1})
107
108
  @abingo.bingo!(test_name)
108
- ex = Abingo::Experiment.first(:test_name => test_name)
109
- ex.best_alternative
109
+ ex = Abingo::Experiment.get(test_name)
110
+ ex.best_alternative.content.should == alternative
110
111
  end
111
112
 
112
- it "should track conversions by conversion name" do
113
- conversion_name = "purchase"
114
- tests = %w{conversionTrackingByConversionNameA conversionTrackingByConversionNameB conversionTrackingByConversionNameC}
115
- tests.map do |test_name|
116
- @abingo.test(test_name, %w{A B}, :conversion => conversion_name)
117
- end
118
-
119
- @abingo.bingo!(conversion_name)
120
- tests.map do |test_name|
121
- ex = Abingo::Experiment.first(:test_name => test_name)
122
- ex.conversions.should == 1
123
- end
124
- end
125
-
126
113
  it "should be possible to short circuit tests" do
127
- conversion_name = "purchase"
128
114
  test_name = "short circuit test"
129
- alt_picked = @abingo.test(test_name, %w{A B}, :conversion => conversion_name)
130
- ex = Abingo::Experiment.first(:test_name => test_name)
115
+ alt_picked = @abingo.test(test_name, %w{A B})
116
+ ex = Abingo::Experiment.get(test_name)
131
117
  alt_not_picked = (%w{A B} - [alt_picked]).first
132
118
 
133
- ex.end_experiment!(@abingo, alt_not_picked, conversion_name)
119
+ ex.end_experiment!(@abingo, alt_not_picked)
134
120
 
135
121
  ex.reload
136
122
  ex.status.should == "Finished"
@@ -140,7 +126,7 @@ describe 'Abingo Specs' do
140
126
 
141
127
  old_identity = @abingo.identity
142
128
  @abingo.identity = "shortCircuitTestNewIdentity"
143
- @abingo.test(test_name, %w{A B}, :conversion => conversion_name)
129
+ @abingo.test(test_name, %w{A B})
144
130
  @abingo.identity = old_identity
145
131
  ex.reload
146
132
 
@@ -5,6 +5,7 @@ require 'spec'
5
5
  require 'sinmetrics'
6
6
 
7
7
  # establish in-memory database for testing
8
+ #DataMapper::Logger.new($stdout, :debug)
8
9
  DataMapper.setup(:default, "sqlite3::memory:")
9
10
 
10
11
  Spec::Runner.configure do |config|
metadata CHANGED
@@ -1,12 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinmetrics
3
3
  version: !ruby/object:Gem::Version
4
+ hash: 15
4
5
  prerelease: false
5
6
  segments:
6
7
  - 0
7
8
  - 0
8
- - 7
9
- version: 0.0.7
9
+ - 8
10
+ version: 0.0.8
10
11
  platform: ruby
11
12
  authors:
12
13
  - Luke Petre
@@ -21,9 +22,11 @@ dependencies:
21
22
  name: dm-core
22
23
  prerelease: false
23
24
  requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
24
26
  requirements:
25
27
  - - ">="
26
28
  - !ruby/object:Gem::Version
29
+ hash: 51
27
30
  segments:
28
31
  - 0
29
32
  - 10
@@ -35,9 +38,11 @@ dependencies:
35
38
  name: dm-aggregates
36
39
  prerelease: false
37
40
  requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
38
42
  requirements:
39
43
  - - ">="
40
44
  - !ruby/object:Gem::Version
45
+ hash: 51
41
46
  segments:
42
47
  - 0
43
48
  - 10
@@ -49,9 +54,11 @@ dependencies:
49
54
  name: dm-observer
50
55
  prerelease: false
51
56
  requirement: &id003 !ruby/object:Gem::Requirement
57
+ none: false
52
58
  requirements:
53
59
  - - ">="
54
60
  - !ruby/object:Gem::Version
61
+ hash: 51
55
62
  segments:
56
63
  - 0
57
64
  - 10
@@ -63,9 +70,11 @@ dependencies:
63
70
  name: dm-timestamps
64
71
  prerelease: false
65
72
  requirement: &id004 !ruby/object:Gem::Requirement
73
+ none: false
66
74
  requirements:
67
75
  - - ">="
68
76
  - !ruby/object:Gem::Version
77
+ hash: 51
69
78
  segments:
70
79
  - 0
71
80
  - 10
@@ -77,9 +86,11 @@ dependencies:
77
86
  name: dm-adjust
78
87
  prerelease: false
79
88
  requirement: &id005 !ruby/object:Gem::Requirement
89
+ none: false
80
90
  requirements:
81
91
  - - ">="
82
92
  - !ruby/object:Gem::Version
93
+ hash: 51
83
94
  segments:
84
95
  - 0
85
96
  - 10
@@ -91,9 +102,11 @@ dependencies:
91
102
  name: activesupport
92
103
  prerelease: false
93
104
  requirement: &id006 !ruby/object:Gem::Requirement
105
+ none: false
94
106
  requirements:
95
107
  - - ~>
96
108
  - !ruby/object:Gem::Version
109
+ hash: 9
97
110
  segments:
98
111
  - 2
99
112
  - 3
@@ -134,23 +147,27 @@ rdoc_options: []
134
147
  require_paths:
135
148
  - lib
136
149
  required_ruby_version: !ruby/object:Gem::Requirement
150
+ none: false
137
151
  requirements:
138
152
  - - ">="
139
153
  - !ruby/object:Gem::Version
154
+ hash: 3
140
155
  segments:
141
156
  - 0
142
157
  version: "0"
143
158
  required_rubygems_version: !ruby/object:Gem::Requirement
159
+ none: false
144
160
  requirements:
145
161
  - - ">="
146
162
  - !ruby/object:Gem::Version
163
+ hash: 3
147
164
  segments:
148
165
  - 0
149
166
  version: "0"
150
167
  requirements: []
151
168
 
152
169
  rubyforge_project:
153
- rubygems_version: 1.3.6
170
+ rubygems_version: 1.3.7
154
171
  signing_key:
155
172
  specification_version: 3
156
173
  summary: simple sinatra metrics extension