sinmetrics 0.0.7 → 0.0.8

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.
@@ -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