Class: Sinatra::AbingoObject

Inherits:
Object
  • Object
show all
Defined in:
lib/sinmetrics/abingo.rb

Defined Under Namespace

Modules: ConversionRate, Statistics Classes: Alternative, Experiment

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app = {}) ⇒ AbingoObject

Defined options:

:enable_specification => if true, allow params[test_name] to override the calculated value for a test.


20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/sinmetrics/abingo.rb', line 20

def initialize app={}
  if app.respond_to?(:options)
    @app = app
    [:identity, :enable_specification, :cache, :salt].each do |var|
      begin
        instance_variable_set("@#{var}", app.options.send("abingo_#{var}"))
      rescue NoMethodError
      end
    end
  else
    [:identity, :enable_specification, :cache, :salt].each do |var|
      instance_variable_set("@#{var}", app[var]) if app.has_key?(var)
    end
  end      
  @identity ||= rand(10 ** 10).to_i.to_s
  @cache ||= ActiveSupport::Cache::MemoryStore.new
  @salt ||= ''
end

Instance Attribute Details

#appObject (readonly)

Returns the value of attribute app.



39
40
41
# File 'lib/sinmetrics/abingo.rb', line 39

def app
  @app
end

#cacheObject (readonly)

Returns the value of attribute cache.



39
40
41
# File 'lib/sinmetrics/abingo.rb', line 39

def cache
  @cache
end

#enable_specificationObject (readonly)

Returns the value of attribute enable_specification.



39
40
41
# File 'lib/sinmetrics/abingo.rb', line 39

def enable_specification
  @enable_specification
end

#identityObject

Returns the value of attribute identity.



40
41
42
# File 'lib/sinmetrics/abingo.rb', line 40

def identity
  @identity
end

#saltObject (readonly)

Returns the value of attribute salt.



39
40
41
# File 'lib/sinmetrics/abingo.rb', line 39

def salt
  @salt
end

Instance Method Details

#alternatives_for_test(test_name) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/sinmetrics/abingo.rb', line 151

def alternatives_for_test(test_name)
  cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")    
  self.cache.fetch(cache_key) do
    Experiment.get(test_name).alternatives.map do |alt| 
      if alt.weight > 1 
        [alt.content] * alt.weight
      else
        alt.content
      end
    end.flatten
  end
end

#bingo!(name = nil, options = {}) ⇒ Object

Scores conversions for tests. test_name_or_array supports three types of input:

A test name: scores a conversion for the named test if the user is participating in it.

An array of either of the above: for each element of the array, process as above.

nil: score a conversion for every test the u



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/sinmetrics/abingo.rb', line 84

def bingo!(name = nil, options = {})
  if name.kind_of? Array
    name.map do |single_test|
      self.bingo!(single_test, options)
    end
  else
    if name.nil?
      #Score all participating tests
      participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []
      participating_tests.each do |participating_test|
        self.bingo!(participating_test, options)
      end
    else
      self.score_conversion!(name.to_s, options)
    end
  end
end

#calculate_alternative_lookup(test_name, alternative_name) ⇒ Object



206
207
208
# File 'lib/sinmetrics/abingo.rb', line 206

def calculate_alternative_lookup(test_name, alternative_name)
  Digest::MD5.hexdigest(self.salt + test_name + alternative_name.to_s)
end

#end_experiment(alternative_id) ⇒ Object



198
199
200
201
202
203
204
# File 'lib/sinmetrics/abingo.rb', line 198

def end_experiment(alternative_id)
  alternative = Alternative.get(alternative_id)
  experiment = alternative.experiment
  if (experiment.status != "Completed")
    experiment.end_experiment!(self, alternative.content)
  end
end

#find_alternative_for_user(test_name, alternatives) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/sinmetrics/abingo.rb', line 138

def find_alternative_for_user(test_name, alternatives)
  cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
  alternative_array = self.cache.fetch(cache_key) do
    self.parse_alternatives(alternatives)
  end

  #Quickly determines what alternative to show a given user.  Given a test name
  #and their identity, we hash them together (which, for MD5, provably introduces
  #enough entropy that we don't care) otherwise
  choice = Digest::MD5.hexdigest(salt.to_s + test_name + self.identity.to_s).to_i(16) % alternative_array.size
  alternative_array[choice]
end

#flip(test_name) ⇒ Object

A simple convenience method for doing an A/B test. Returns true or false. If you pass it a block, it will bind the choice to the variable given to the block.



44
45
46
47
48
49
50
51
# File 'lib/sinmetrics/abingo.rb', line 44

def flip(test_name)
  choice = test(test_name, [true, false])
  if block_given?
    yield choice
  else
    choice
  end
end

#parse_alternatives(alternatives) ⇒ Object

For programmer convenience, we allow you to specify what the alternatives for an experiment are in a few ways. Thus, we need to actually be able to handle all of them. We fire this parser very infrequently (once per test, typically) so it can be as complicated as we want.

Integer => a number 1 through N
Range   => a number within the range
Array   => an element of the array.
Hash    => assumes a hash of something to int.  We pick one of the 
           somethings, weighted accorded to the ints provided.  e.g.
           {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.

Alternatives are always represented internally as an array.



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/sinmetrics/abingo.rb', line 116

def parse_alternatives(alternatives)
  if alternatives.kind_of? Array
    return alternatives
  elsif alternatives.kind_of? Integer
    return (1..alternatives).to_a
  elsif alternatives.kind_of? Range
    return alternatives.to_a
  elsif alternatives.kind_of? Hash
    alternatives_array = []
    alternatives.each do |key, value|
      if value.kind_of? Integer
        alternatives_array += [key] * value
      else
        raise "You gave a hash with #{key} => #{value} as an element.  The value must be an integral weight."
      end
    end
    return alternatives_array
  else
    raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
  end
end

#score_conversion!(test_name, options = {}) ⇒ Object



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/sinmetrics/abingo.rb', line 164

def score_conversion!(test_name, options = {})
  participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []
  if options[:assume_participation] || participating_tests.include?(test_name)
    cache_key = "Abingo::conversions(#{identity},#{test_name}"
    if options[:multiple_conversions] || !cache.read(cache_key)
      viewed_alternative = find_alternative_for_user(test_name, alternatives_for_test(test_name))
      lookup = calculate_alternative_lookup(test_name, viewed_alternative)
      Alternative.all(:lookup => lookup).adjust!(:conversions => 1)
      if cache.exist?(cache_key)
        cache.increment(cache_key)
      else
        cache.write(cache_key, 1)
      end
    end
  end
end

#score_participation!(test_name, options = {}) ⇒ Object



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/sinmetrics/abingo.rb', line 181

def score_participation!(test_name, options = {})
  participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []

  #Set this user to participate in this experiment, and increment participants count.
  if options[:multiple_participation] || !(participating_tests.include?(test_name))
    participating_tests = participating_tests.dup if participating_tests.frozen?
    unless participating_tests.include?(test_name)
      participating_tests << test_name
      cache.write("Abingo::participating_tests::#{identity}", participating_tests)
    end
    
    choice = find_alternative_for_user(test_name, alternatives_for_test(test_name))
    lookup = calculate_alternative_lookup(test_name, choice)
    Alternative.all(:lookup => lookup).adjust!(:participants => 1)
  end
end

#test(test_name, alternatives, options = {}) ⇒ Object

This is the meat of A/Bingo. options accepts

:multiple_participation (true or false)
:no_particiation (true or false)


57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/sinmetrics/abingo.rb', line 57

def test(test_name, alternatives, options = {})
  experiment = Experiment.get(test_name)
  experiment ||= Experiment.start_experiment!(self, test_name, parse_alternatives(alternatives))

  # Test has been stopped, pick canonical alternative.
  unless experiment.short_circuit.nil?
    return experiment.short_circuit
  end

  choice = find_alternative_for_user(test_name, alternatives)
  score_participation!(test_name, options) unless options[:no_participation]

  if block_given?
    yield(choice)
  else
    choice
  end
end