Class: TrailGuide::Experiment

Inherits:
Object
  • Object
show all
Defined in:
lib/trail_guide/experiment.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(participant) ⇒ Experiment



250
251
252
# File 'lib/trail_guide/experiment.rb', line 250

def initialize(participant)
  @participant = participant
end

Instance Attribute Details

#participantObject (readonly)

Returns the value of attribute participant.



244
245
246
# File 'lib/trail_guide/experiment.rb', line 244

def participant
  @participant
end

Class Method Details

.algorithm(algo = nil) ⇒ Object



33
34
35
36
# File 'lib/trail_guide/experiment.rb', line 33

def algorithm(algo=nil)
  @algorithm = TrailGuide::Algorithms.algorithm(algo) unless algo.nil?
  @algorithm ||= TrailGuide::Algorithms.algorithm(TrailGuide.configuration.algorithm)
end

.allow_multiple_conversions(allow) ⇒ Object



100
101
102
# File 'lib/trail_guide/experiment.rb', line 100

def allow_multiple_conversions(allow)
  @allow_multiple_conversions = allow
end

.allow_multiple_conversions?Boolean



104
105
106
# File 'lib/trail_guide/experiment.rb', line 104

def allow_multiple_conversions?
  !!@allow_multiple_conversions
end

.allow_multiple_goals(allow) ⇒ Object



108
109
110
# File 'lib/trail_guide/experiment.rb', line 108

def allow_multiple_goals(allow)
  @allow_multiple_goals = allow
end

.allow_multiple_goals?Boolean



112
113
114
# File 'lib/trail_guide/experiment.rb', line 112

def allow_multiple_goals?
  !!@allow_multiple_goals
end

.as_json(opts = {}) ⇒ Object



230
231
232
233
234
235
236
237
# File 'lib/trail_guide/experiment.rb', line 230

def as_json(opts={})
  # TODO fill in the rest of the values i've added
  {
    experiment_name: experiment_name,
    algorithm: algorithm,
    variants: variants.as_json
  }
end

.callbacksObject



116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/trail_guide/experiment.rb', line 116

def callbacks
  @callbacks ||= begin
    callbacks = {
      on_choose:   [TrailGuide.configuration.on_experiment_choose].compact,
      on_use:      [TrailGuide.configuration.on_experiment_use].compact,
      on_convert:  [TrailGuide.configuration.on_experiment_convert].compact,
      on_start:    [TrailGuide.configuration.on_experiment_start].compact,
      on_stop:     [TrailGuide.configuration.on_experiment_stop].compact,
      on_reset:    [TrailGuide.configuration.on_experiment_reset].compact,
      on_delete:   [TrailGuide.configuration.on_experiment_delete].compact,
    }
  end
end

.config_algorithmObject



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/trail_guide/experiment.rb', line 16

def config_algorithm
  config_algo = TrailGuide.configuration.algorithm
  case config_algo
  when :weighted
    config_algo = TrailGuide::Algorithms::Weighted
  when :bandit
    config_algo = TrailGuide::Algorithms::Bandit
  when :distributed
    config_algo = TrailGuide::Algorithms::Distributed
  when :random
    config_algo = TrailGuide::Algorithms::Random
  else
    config_algo = config_algo.constantize if config_algo.is_a?(String)
  end
  config_algo
end

.control(name = nil) ⇒ Object



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/trail_guide/experiment.rb', line 67

def control(name=nil)
  return variants.find { |var| var.control? } || variants.first if name.nil?

  variants.each(&:variant!)
  var_idx = variants.index { |var| var == name }

  if var_idx.nil?
    variant = Variant.new(self, name, control: true)
  else
    variant = variants.slice!(var_idx, 1)[0]
    variant.control!
  end

  variants.unshift(variant)
  return variant
end

.declare_winner!(variant) ⇒ Object



194
195
196
197
# File 'lib/trail_guide/experiment.rb', line 194

def declare_winner!(variant)
  variant = variant.name if variant.is_a?(Variant)
  TrailGuide.redis.hset(storage_key, 'winner', variant.to_s.underscore)
end

.delete!Object



217
218
219
220
221
222
# File 'lib/trail_guide/experiment.rb', line 217

def delete!
  variants.each(&:delete!)
  deleted = TrailGuide.redis.del(storage_key)
  run_callbacks(:on_delete)
  deleted
end

.experiment_name(name = nil) ⇒ Object

TODO could probably move all this configuration stuff at the class level into a canfig object instead…?



11
12
13
14
# File 'lib/trail_guide/experiment.rb', line 11

def experiment_name(name=nil)
  @experiment_name = name.to_s.underscore.to_sym unless name.nil?
  @experiment_name || self.name.try(:underscore).try(:to_sym)
end

.funnel(name) ⇒ Object Also known as: goal



84
85
86
# File 'lib/trail_guide/experiment.rb', line 84

def funnel(name)
  funnels << name.to_s.underscore.to_sym
end

.funnels(arr = nil) ⇒ Object Also known as: goals



89
90
91
92
# File 'lib/trail_guide/experiment.rb', line 89

def funnels(arr=nil)
  @funnels = arr unless arr.nil?
  @funnels ||= []
end

.inherited(child) ⇒ Object



4
5
6
7
# File 'lib/trail_guide/experiment.rb', line 4

def inherited(child)
  # TODO allow inheriting algo, variants, goals, metrics, etc.
  TrailGuide::Catalog.register(child)
end

.metric(key = nil) ⇒ Object



95
96
97
98
# File 'lib/trail_guide/experiment.rb', line 95

def metric(key=nil)
  @metric = key.to_s.underscore.to_sym unless key.nil?
  @metric ||= experiment_name
end

.on_choose(meth = nil, &block) ⇒ Object



130
131
132
# File 'lib/trail_guide/experiment.rb', line 130

def on_choose(meth=nil, &block)
  callbacks[:on_choose] << (meth || block)
end

.on_convert(meth = nil, &block) ⇒ Object



138
139
140
# File 'lib/trail_guide/experiment.rb', line 138

def on_convert(meth=nil, &block)
  callbacks[:on_convert] << (meth || block)
end

.on_delete(meth = nil, &block) ⇒ Object



154
155
156
# File 'lib/trail_guide/experiment.rb', line 154

def on_delete(meth=nil, &block)
  callbacks[:on_delete] << (meth || block)
end

.on_reset(meth = nil, &block) ⇒ Object



150
151
152
# File 'lib/trail_guide/experiment.rb', line 150

def on_reset(meth=nil, &block)
  callbacks[:on_reset] << (meth || block)
end

.on_start(meth = nil, &block) ⇒ Object



142
143
144
# File 'lib/trail_guide/experiment.rb', line 142

def on_start(meth=nil, &block)
  callbacks[:on_start] << (meth || block)
end

.on_stop(meth = nil, &block) ⇒ Object



146
147
148
# File 'lib/trail_guide/experiment.rb', line 146

def on_stop(meth=nil, &block)
  callbacks[:on_stop] << (meth || block)
end

.on_use(meth = nil, &block) ⇒ Object



134
135
136
# File 'lib/trail_guide/experiment.rb', line 134

def on_use(meth=nil, &block)
  callbacks[:on_use] << (meth || block)
end

.persisted?Boolean



208
209
210
# File 'lib/trail_guide/experiment.rb', line 208

def persisted?
  TrailGuide.redis.exists(storage_key)
end

.reset!Object



224
225
226
227
228
# File 'lib/trail_guide/experiment.rb', line 224

def reset!
  reset = (delete! && save!)
  run_callbacks(:on_reset)
  reset
end

.resettable(reset) ⇒ Object



38
39
40
# File 'lib/trail_guide/experiment.rb', line 38

def resettable(reset)
  @resettable = reset
end

.resettable?Boolean



42
43
44
45
46
47
48
# File 'lib/trail_guide/experiment.rb', line 42

def resettable?
  if @resettable.nil?
    !TrailGuide.configuration.reset_manually
  else
    !!@resettable
  end
end

.run_callbacks(hook, *args) ⇒ Object



158
159
160
161
162
163
164
165
166
167
168
# File 'lib/trail_guide/experiment.rb', line 158

def run_callbacks(hook, *args)
  return unless callbacks[hook]
  args.unshift(self)
  callbacks[hook].each do |callback|
    if callback.respond_to?(:call)
      callback.call(*args)
    else
      send(callback, *args)
    end
  end
end

.save!Object



212
213
214
215
# File 'lib/trail_guide/experiment.rb', line 212

def save!
  variants.each(&:save!)
  TrailGuide.redis.hsetnx(storage_key, 'name', experiment_name)
end

.start!Object



170
171
172
173
174
175
176
# File 'lib/trail_guide/experiment.rb', line 170

def start!
  return false if started?
  save! unless persisted?
  started = TrailGuide.redis.hset(storage_key, 'started_at', Time.now.to_i)
  run_callbacks(:on_start)
  started
end

.started?Boolean



190
191
192
# File 'lib/trail_guide/experiment.rb', line 190

def started?
  !!started_at
end

.started_atObject



185
186
187
188
# File 'lib/trail_guide/experiment.rb', line 185

def started_at
  started = TrailGuide.redis.hget(storage_key, 'started_at')
  return Time.at(started.to_i) if started
end

.stop!Object



178
179
180
181
182
183
# File 'lib/trail_guide/experiment.rb', line 178

def stop!
  return false unless started?
  stopped = TrailGuide.redis.hdel(storage_key, 'started_at')
  run_callbacks(:on_stop)
  stopped
end

.storage_keyObject



239
240
241
# File 'lib/trail_guide/experiment.rb', line 239

def storage_key
  experiment_name
end

.variant(name, metadata: {}, weight: 1, control: false) ⇒ Object

Raises:

  • (ArgumentError)


50
51
52
53
54
55
56
# File 'lib/trail_guide/experiment.rb', line 50

def variant(name, metadata: {}, weight: 1, control: false)
  raise ArgumentError, "The variant #{name} already exists in experiment #{experiment_name}" if variants.any? { |var| var == name }
  control = true if variants.empty?
  variant = Variant.new(self, name, metadata: , weight: weight, control: control)
  variants << variant
  variant
end

.variants(include_control = true) ⇒ Object



58
59
60
61
62
63
64
65
# File 'lib/trail_guide/experiment.rb', line 58

def variants(include_control=true)
  @variants ||= []
  if include_control
    @variants
  else
    @variants.select { |var| !var.control? }
  end
end

.winnerObject



199
200
201
202
# File 'lib/trail_guide/experiment.rb', line 199

def winner
  winner = TrailGuide.redis.hget(storage_key, 'winner')
  return variants.find { |var| var == winner } if winner
end

.winner?Boolean



204
205
206
# File 'lib/trail_guide/experiment.rb', line 204

def winner?
  !!winner
end

Instance Method Details

#algorithmObject



254
255
256
# File 'lib/trail_guide/experiment.rb', line 254

def algorithm
  @algorithm ||= self.class.algorithm.new(self)
end

#choose!(metadata: nil, **opts) ⇒ Object



258
259
260
261
262
263
264
# File 'lib/trail_guide/experiment.rb', line 258

def choose!(metadata: nil, **opts)
  return control if TrailGuide.configuration.disabled

  variant = choose_variant!(metadata: , **opts)
  run_callbacks(:on_use, variant, )
  variant
end

#choose_variant!(override: nil, excluded: false, metadata: nil) ⇒ Object



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/trail_guide/experiment.rb', line 266

def choose_variant!(override: nil, excluded: false, metadata: nil)
  return control if TrailGuide.configuration.disabled
  if override.present?
    variant = variants.find { |var| var == override }
    return variant unless TrailGuide.configuration.store_override && started?
  else
    return winner if winner?
    return control if excluded
    return control if !started? && TrailGuide.configuration.start_manually
    start! unless started?
    return variants.find { |var| var == participant[storage_key] } if participating?
    return control unless TrailGuide.configuration.allow_multiple_experiments == true || !participant.participating_in_active_experiments?(TrailGuide.configuration.allow_multiple_experiments == false) 

    variant = algorithm.choose!(metadata: )
  end

  participant.participating!(variant)
  variant.increment_participation!
  run_callbacks(:on_choose, variant, )
  variant
end

#convert!(checkpoint = nil, metadata: nil) ⇒ Object

Raises:

  • (ArgumentError)


288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/trail_guide/experiment.rb', line 288

def convert!(checkpoint=nil, metadata: nil)
  return false unless participating?
  raise ArgumentError, "You must provide a valid goal checkpoint for #{experiment_name}" unless checkpoint.present? || funnels.empty?
  raise ArgumentError, "Unknown goal checkpoint: #{checkpoint}" unless checkpoint.nil? || funnels.any? { |funnel| funnel == checkpoint.to_s.underscore.to_sym }
  # TODO eventually allow progressing through funnel checkpoints towards goals
  if converted?(checkpoint)
    return false unless allow_multiple_conversions?
  elsif converted?
    return false unless allow_multiple_goals?
  end

  variant = variants.find { |var| var == participant[storage_key] }
  # TODO eventually only reset if we're at the final goal in a funnel
  participant.converted!(variant, checkpoint, reset: resettable?)
  variant.increment_conversion!(checkpoint)
  run_callbacks(:on_convert, variant, checkpoint, )
  variant
end

#converted?(checkpoint = nil) ⇒ Boolean



311
312
313
# File 'lib/trail_guide/experiment.rb', line 311

def converted?(checkpoint=nil)
  participant.converted?(self, checkpoint)
end

#participating?Boolean



307
308
309
# File 'lib/trail_guide/experiment.rb', line 307

def participating?
  participant.participating?(self)
end

#run_callbacks(hook, *args) ⇒ Object



315
316
317
318
319
320
321
322
323
324
325
# File 'lib/trail_guide/experiment.rb', line 315

def run_callbacks(hook, *args)
  return unless callbacks[hook]
  args.unshift(self)
  callbacks[hook].each do |callback|
    if callback.respond_to?(:call)
      callback.call(*args)
    else
      send(callback, *args)
    end
  end
end