trailguide 0.3.0 → 0.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1f31e734d2b0165ffe08887a4e7a21cceebe8c6aba8c4ff72d9393f1fd6095a
4
- data.tar.gz: ea8cd225a315add5f4512f8bc38bd28bc372741c45185eb4c987069165c3ad4b
3
+ metadata.gz: ad497aad0d4ac4af551fac70eb9191271586a4d3a3d9a6f45b9ae5879fb1cbaf
4
+ data.tar.gz: a2c20d6786785ee59e7c9bd15f14bb155078ff9f84ef4d1767aa939b3266cb00
5
5
  SHA512:
6
- metadata.gz: ccb5f9cd9e367481380b8afbc6a763149967bd59f0bbe6d7f0c0693d90ccae7d0b6a16bf9e2544ef4362504cb417cb327f44001a044d8bbc4e318a1377b6ac45
7
- data.tar.gz: 19b6761660d76db06b36114496fd9f835d08fdf70e99a7e117369d800175a79f18e21c6436f7f37a84529e3370bdae2f8285a49e25ae00182cc05f801c8daea9
6
+ metadata.gz: 4ebc9638bbdbbb2c9ad168441643fa61d12e9d1ecef0ca945fcd28157b2169ddf549b87f7e691c0735bd6c952be4552517f20cbb94a0002ea966e3351d845c02
7
+ data.tar.gz: 135fcf0f3bde5583232921bbb40c981ef29fbe044a178e41bf41891af9a5371fae403aa0de83000ed1b9ed4ba27bb886ea2a9e5e3d0ad06236cd52ec15c0d7d3
@@ -16,7 +16,7 @@
16
16
  <span class="fas fa-flask" data-toggle="tooltip" title="experiments"></span>
17
17
  </div>
18
18
  <div class="col-sm-3 text-right">
19
- <small class="text-muted"><%= TrailGuide.redis._client.id %><%= "/#{TrailGuide.redis.namespace}" if TrailGuide.redis.respond_to?(:namespace) %></small>
19
+ <small class="text-muted"><%= TrailGuide.redis_client.id %><%= "/#{TrailGuide.redis.namespace}" if TrailGuide.redis.respond_to?(:namespace) %></small>
20
20
  <button type="button" class="btn btn-link text-muted" style="font-size: 80%; margin: 0; padding: 0;" data-toggle="modal" data-target="#import-modal" data-tooltip="tooltip" title="Import/Export">
21
21
  <span class="fas fa-file-download"></span>
22
22
  </button>
@@ -27,6 +27,10 @@ module TrailGuide
27
27
  end
28
28
  end
29
29
 
30
+ def redis_client
31
+ redis.try(:_client) || redis.try(:client)
32
+ end
33
+
30
34
  def ignore_orphaned_groups?
31
35
  !!self[:ignore_orphaned_groups]
32
36
  end
@@ -1,9 +1,20 @@
1
1
  require "trail_guide/experiments/config"
2
+ require "trail_guide/experiments/persistence"
3
+ require "trail_guide/experiments/lifecycle"
4
+ require "trail_guide/experiments/enrollment"
5
+ require "trail_guide/experiments/conversion"
6
+ require "trail_guide/experiments/results"
2
7
  require "trail_guide/experiments/participant"
3
8
 
4
9
  module TrailGuide
5
10
  module Experiments
6
11
  class Base
12
+ include Persistence
13
+ include Lifecycle
14
+ include Enrollment
15
+ include Conversion
16
+ include Results
17
+
7
18
  class << self
8
19
  delegate :groups, :algorithm, :control, :goals, :callbacks, :combined,
9
20
  :combined?, :allow_multiple_conversions?, :allow_multiple_goals?,
@@ -23,10 +34,6 @@ module TrailGuide
23
34
  configuration.configure(*args, &block)
24
35
  end
25
36
 
26
- def adapter
27
- @adapter ||= TrailGuide::Adapters::Experiments::Redis.new(self)
28
- end
29
-
30
37
  # TODO alias name once specs have solid coverage
31
38
  def experiment_name
32
39
  configuration.name
@@ -48,173 +55,6 @@ module TrailGuide
48
55
  combined.map { |combo| TrailGuide.catalog.find(combo) }
49
56
  end
50
57
 
51
- def run_callbacks(hook, *args)
52
- return unless callbacks[hook]
53
- return args[0] if hook == :rollout_winner # TODO do we need to account for this case here at the class level?
54
- args.unshift(self)
55
- callbacks[hook].each do |callback|
56
- if callback.respond_to?(:call)
57
- callback.call(*args)
58
- else
59
- send(callback, *args)
60
- end
61
- end
62
- end
63
-
64
- def start!(context=nil)
65
- return false if started?
66
- save! unless persisted?
67
- started = adapter.set(:started_at, Time.now.to_i)
68
- run_callbacks(:on_start, context)
69
- started
70
- end
71
-
72
- def schedule!(start_at, stop_at=nil, context=nil)
73
- return false if started?
74
- save! unless persisted?
75
- scheduled = adapter.set(:started_at, start_at.to_i)
76
- adapter.set(:stopped_at, stop_at.to_i) if stop_at
77
- run_callbacks(:on_schedule, start_at, stop_at, context)
78
- scheduled
79
- end
80
-
81
- def pause!(context=nil)
82
- return false unless running? && configuration.can_resume?
83
- paused = adapter.set(:paused_at, Time.now.to_i)
84
- run_callbacks(:on_pause, context)
85
- paused
86
- end
87
-
88
- def stop!(context=nil)
89
- return false unless started? && !stopped?
90
- stopped = adapter.set(:stopped_at, Time.now.to_i)
91
- run_callbacks(:on_stop, context)
92
- stopped
93
- end
94
-
95
- def resume!(context=nil)
96
- return false unless paused? && configuration.can_resume?
97
- resumed = adapter.delete(:paused_at)
98
- run_callbacks(:on_resume, context)
99
- !!resumed
100
- end
101
-
102
- def started_at
103
- started = adapter.get(:started_at)
104
- return Time.at(started.to_i) if started
105
- end
106
-
107
- def paused_at
108
- paused = adapter.get(:paused_at)
109
- return Time.at(paused.to_i) if paused
110
- end
111
-
112
- def stopped_at
113
- stopped = adapter.get(:stopped_at)
114
- return Time.at(stopped.to_i) if stopped
115
- end
116
-
117
- def started?
118
- time = started_at
119
- time && time <= Time.now
120
- end
121
-
122
- def scheduled?
123
- time = started_at
124
- time && time > Time.now
125
- end
126
-
127
- def paused?
128
- time = paused_at
129
- time && time <= Time.now
130
- end
131
-
132
- def stopped?
133
- time = stopped_at
134
- time && time <= Time.now
135
- end
136
-
137
- def running?
138
- started? && !paused? && !stopped?
139
- end
140
-
141
- def calibrating?
142
- enable_calibration? && start_manually? && !started?
143
- end
144
-
145
- def fresh?
146
- !started? && !scheduled? && !winner?
147
- end
148
-
149
- def declare_winner!(variant, context=nil)
150
- variant = variants.find { |var| var == variant } unless variant.is_a?(Variant)
151
- return false unless variant.present? && variant.experiment == self
152
- run_callbacks(:on_winner, variant, context)
153
- adapter.set(:winner, variant.name)
154
- variant
155
- end
156
-
157
- def clear_winner!
158
- adapter.delete(:winner)
159
- end
160
-
161
- def winner
162
- winner = adapter.get(:winner)
163
- return variants.find { |var| var == winner } if winner
164
- end
165
-
166
- def winner?
167
- if combined?
168
- combined.all? { |combo| TrailGuide.catalog.find(combo).winner? }
169
- else
170
- adapter.exists?(:winner)
171
- end
172
- end
173
-
174
- def persisted?
175
- adapter.persisted?
176
- end
177
-
178
- def save!
179
- combined_experiments.each(&:save!)
180
- variants.each(&:save!)
181
- adapter.setnx(:name, experiment_name)
182
- end
183
-
184
- def delete!(context=nil)
185
- combined.each { |combo| TrailGuide.catalog.find(combo).delete! }
186
- variants.each(&:delete!)
187
- deleted = adapter.destroy
188
- run_callbacks(:on_delete, context)
189
- true
190
- end
191
-
192
- def reset!(context=nil)
193
- delete!(context)
194
- save!
195
- run_callbacks(:on_reset, context)
196
- true
197
- end
198
-
199
- def participants
200
- variants.sum(&:participants)
201
- end
202
-
203
- def converted(checkpoint=nil)
204
- variants.sum { |var| var.converted(checkpoint) }
205
- end
206
-
207
- def unconverted
208
- participants - converted
209
- end
210
-
211
- def target_sample_size_reached?
212
- return true unless configuration.target_sample_size
213
- return true if participants >= configuration.target_sample_size
214
- return false
215
- end
216
-
217
- # export the experiment state (not config) as json
218
58
  def as_json(opts={})
219
59
  { experiment_name => {
220
60
  started_at: started_at,
@@ -224,10 +64,6 @@ module TrailGuide
224
64
  variants: variants.map(&:as_json).reduce({}) { |r,v| r.merge!(v) },
225
65
  } }
226
66
  end
227
-
228
- def storage_key
229
- configuration.name
230
- end
231
67
  end
232
68
 
233
69
  attr_reader :participant
@@ -237,181 +73,7 @@ module TrailGuide
237
73
  :enable_calibration?, :track_winner_conversions?, :callbacks, to: :class
238
74
 
239
75
  def initialize(participant)
240
- @participant = TrailGuide::Experiments::Participant.new(self, participant)
241
- end
242
-
243
- def algorithm
244
- @algorithm ||= self.class.algorithm.new(self)
245
- end
246
-
247
- def winning_variant
248
- return nil unless winner?
249
- run_callbacks(:rollout_winner, winner, participant)
250
- end
251
-
252
- def choose!(override: nil, metadata: nil, **opts)
253
- return control if TrailGuide.configuration.disabled
254
-
255
- variant = choose_variant!(override: override, metadata: metadata, **opts)
256
- run_callbacks(:on_use, variant, participant, metadata)
257
- variant
258
- rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e
259
- run_callbacks(:on_redis_failover, e)
260
- return variants.find { |var| var == override } || control if override.present?
261
- return control
262
- end
263
-
264
- def choose_variant!(override: nil, excluded: false, metadata: nil)
265
- return control if TrailGuide.configuration.disabled
266
-
267
- if override.present?
268
- variant = variants.find { |var| var == override } || control
269
- if running? && !is_combined?
270
- variant.increment_participation! if configuration.track_override
271
- participant.participating!(variant) if configuration.store_override
272
- end
273
- return variant
274
- end
275
-
276
- if winner?
277
- variant = winning_variant
278
- if track_winner_conversions? && running?
279
- variant.increment_participation! unless participant.variant == variant
280
- participant.exit! if participant.participating? && participant.variant != variant
281
- participant.participating!(variant)
282
- end
283
- return variant
284
- end
285
-
286
- return control if excluded || stopped?
287
-
288
- if !started? && start_manually?
289
- if enable_calibration?
290
- unless participant.variant == control
291
- control.increment_participation!
292
- parent.control.increment_participation! if is_combined?
293
- end
294
-
295
- if participant.participating? && participant.variant != control
296
- participant.exit!
297
- parent.participant.exit! if is_combined?
298
- end
299
-
300
- participant.participating!(control)
301
- parent.participant.participating!(parent.control) if is_combined?
302
- end
303
- return control
304
- end
305
-
306
- start! unless started? || scheduled?
307
- return control unless running?
308
-
309
- # only re-use the variant for experiments that store participation,
310
- # all other (i.e. content-based) experiments should re-select and
311
- # re-assign on enrollment
312
- if configuration.sticky_assignment? && participant.participating?
313
- variant = participant.variant
314
- participant.participating!(variant)
315
- return variant
316
- end
317
-
318
- return control unless is_combined? || TrailGuide.configuration.allow_multiple_experiments == true || !participant.participating_in_active_experiments?(TrailGuide.configuration.allow_multiple_experiments == false)
319
- return control unless allow_participation?(metadata)
320
-
321
- variant = algorithm_choose!(metadata: metadata)
322
- variant.increment_participation!
323
- participant.participating!(variant)
324
- run_callbacks(:on_choose, variant, participant, metadata)
325
- variant
326
- end
327
-
328
- def algorithm_choose!(metadata: nil)
329
- algorithm.choose!(metadata: metadata)
330
- end
331
-
332
- def convert!(checkpoint=nil, metadata: nil)
333
- if !started?
334
- return false unless enable_calibration?
335
- variant = participant.variant
336
- return false unless variant.present? && variant == control
337
- else
338
- return false unless running?
339
- variant = participant.variant
340
- return false unless variant.present?
341
-
342
- if winner?
343
- return false unless track_winner_conversions? && variant == winner
344
- end
345
- end
346
-
347
- if checkpoint.nil?
348
- raise InvalidGoalError, "You must provide a valid goal checkpoint for `#{experiment_name}`." unless goals.empty?
349
- else
350
- goal = goals.find { |g| g == checkpoint }
351
- raise InvalidGoalError, "Invalid goal checkpoint `#{checkpoint}` for `#{experiment_name}`." if goal.nil?
352
- checkpoint = goal
353
- end
354
-
355
- # TODO eventually allow progressing through funnel checkpoints towards goals
356
- if participant.converted?(checkpoint)
357
- return false unless (checkpoint.nil? && allow_multiple_conversions?) || (checkpoint.present? && checkpoint.allow_multiple_conversions?)
358
- elsif participant.converted?
359
- return false unless allow_multiple_goals?
360
- end
361
- return false unless allow_conversion?(variant, checkpoint, metadata)
362
-
363
- # TODO only reset if !reset_manually? AND they've converted all goals if
364
- # allow_multiple_goals? is set
365
- # TODO what should happen when allow_multiple_conversions? and !reset_manually?
366
- # TODO eventually only reset if we're at the final goal in a funnel
367
- participant.converted!(variant, checkpoint, reset: !reset_manually?)
368
- variant.increment_conversion!(checkpoint)
369
- if checkpoint.nil?
370
- run_callbacks(:on_convert, checkpoint, variant, participant, metadata)
371
- else
372
- checkpoint.run_callbacks(:on_convert, self, variant, participant, metadata)
373
- end
374
- variant
375
- rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e
376
- run_callbacks(:on_redis_failover, e)
377
- return false
378
- end
379
-
380
- def allow_participation?(metadata=nil)
381
- return true if callbacks[:allow_participation].empty?
382
- run_callbacks(:allow_participation, true, participant, metadata)
383
- end
384
-
385
- def allow_conversion?(variant, checkpoint=nil, metadata=nil)
386
- if checkpoint.nil?
387
- return true if callbacks[:allow_conversion].empty?
388
- # TODO why pass checkpoint through here if checkpoints are handled by their own method? it will always be nil here given current logic
389
- run_callbacks(:allow_conversion, true, checkpoint, variant, participant, metadata)
390
- else
391
- checkpoint.allow_conversion?(self, variant, metadata)
392
- end
393
- end
394
-
395
- def run_callbacks(hook, *args)
396
- return unless callbacks[hook]
397
- if [:allow_participation, :allow_conversion, :rollout_winner].include?(hook)
398
- callbacks[hook].reduce(args.slice!(0,1)[0]) do |result, callback|
399
- if callback.respond_to?(:call)
400
- callback.call(self, result, *args)
401
- else
402
- send(callback, self, result, *args)
403
- end
404
- end
405
- else
406
- args.unshift(self)
407
- callbacks[hook].each do |callback|
408
- if callback.respond_to?(:call)
409
- callback.call(*args)
410
- else
411
- send(callback, *args)
412
- end
413
- end
414
- end
76
+ @participant = Experiments::Participant.new(self, participant)
415
77
  end
416
78
 
417
79
  def combined_experiments
@@ -419,62 +81,6 @@ module TrailGuide
419
81
  combo.new(participant.participant)
420
82
  end
421
83
  end
422
-
423
- ### MEMOIZATOIN ###
424
- # This is a lot of seemingly unnecessary duplication, but it really helps
425
- # to cut down on the number of redis requests while still being
426
- # thread-safe by memoizing these methods/values here at the instance level
427
-
428
- def start!
429
- @started_at = nil
430
- self.class.start!
431
- end
432
-
433
- def started_at
434
- @started_at ||= self.class.started_at
435
- end
436
-
437
- def paused_at
438
- @paused_at ||= self.class.paused_at
439
- end
440
-
441
- def stopped_at
442
- @stopped_at ||= self.class.stopped_at
443
- end
444
-
445
- def winner
446
- @winner ||= self.class.winner
447
- end
448
-
449
- def scheduled?
450
- started_at && started_at > Time.now
451
- end
452
-
453
- def started?
454
- started_at && started_at <= Time.now
455
- end
456
-
457
- def paused?
458
- paused_at && paused_at <= Time.now
459
- end
460
-
461
- def stopped?
462
- stopped_at && stopped_at <= Time.now
463
- end
464
-
465
- def running?
466
- started? && !paused? && !stopped?
467
- end
468
-
469
- def calibrating?
470
- enable_calibration? && start_manually? && !started?
471
- end
472
-
473
- def winner?
474
- return @has_winner unless @has_winner.nil?
475
- @has_winner = self.class.winner?
476
- end
477
-
478
84
  end
479
85
  end
480
86
  end
@@ -19,11 +19,7 @@ module TrailGuide
19
19
  def default_config
20
20
  DEFAULT_KEYS.map do |key|
21
21
  [key, nil]
22
- end.to_h.merge({
23
- variants: [],
24
- goals: [],
25
- combined: []
26
- }).merge(callback_config)
22
+ end.to_h.merge(callback_config)
27
23
  end
28
24
 
29
25
  def callback_config
@@ -44,7 +40,7 @@ module TrailGuide
44
40
  opts[:name] = nil
45
41
  opts[:goals] = ancestor.goals.dup
46
42
  opts[:combined] = ancestor.combined.dup
47
- opts[:variants] = ancestor.variants.map { |var| var.dup(experiment) }
43
+ opts[:variants] = ancestor.variants.dup(experiment)
48
44
  opts = opts.merge(ancestor.callbacks.map { |k,v| [k,[v].flatten.compact] }.to_h)
49
45
  end
50
46
  super(*args, **opts, &block)
@@ -99,7 +95,7 @@ module TrailGuide
99
95
  end
100
96
 
101
97
  def variants
102
- self[:variants]
98
+ self[:variants] ||= TrailGuide::Variants.new
103
99
  end
104
100
 
105
101
  def variant(varname, metadata: {}, weight: 1, control: false)
@@ -202,7 +198,7 @@ module TrailGuide
202
198
  end
203
199
 
204
200
  def combined
205
- self[:combined]
201
+ self[:combined] ||= []
206
202
  end
207
203
 
208
204
  def combined?
@@ -0,0 +1,63 @@
1
+ module TrailGuide
2
+ module Experiments
3
+ module Conversion
4
+ def convert!(checkpoint=nil, metadata: nil)
5
+ if !started?
6
+ return false unless enable_calibration?
7
+ variant = participant.variant
8
+ return false unless variant.present? && variant == control
9
+ else
10
+ return false unless running?
11
+ variant = participant.variant
12
+ return false unless variant.present?
13
+
14
+ if winner?
15
+ return false unless track_winner_conversions? && variant == winner
16
+ end
17
+ end
18
+
19
+ if checkpoint.nil?
20
+ raise InvalidGoalError, "You must provide a valid goal checkpoint for `#{experiment_name}`." unless goals.empty?
21
+ else
22
+ goal = goals.find { |g| g == checkpoint }
23
+ raise InvalidGoalError, "Invalid goal checkpoint `#{checkpoint}` for `#{experiment_name}`." if goal.nil?
24
+ checkpoint = goal
25
+ end
26
+
27
+ # TODO eventually allow progressing through funnel checkpoints towards goals
28
+ if participant.converted?(checkpoint)
29
+ return false unless (checkpoint.nil? && allow_multiple_conversions?) || (checkpoint.present? && checkpoint.allow_multiple_conversions?)
30
+ elsif participant.converted?
31
+ return false unless allow_multiple_goals?
32
+ end
33
+ return false unless allow_conversion?(variant, checkpoint, metadata)
34
+
35
+ # TODO only reset if !reset_manually? AND they've converted all goals if
36
+ # allow_multiple_goals? is set
37
+ # TODO what should happen when allow_multiple_conversions? and !reset_manually?
38
+ # TODO eventually only reset if we're at the final goal in a funnel
39
+ participant.converted!(variant, checkpoint, reset: !reset_manually?)
40
+ variant.increment_conversion!(checkpoint)
41
+ if checkpoint.nil?
42
+ run_callbacks(:on_convert, checkpoint, variant, participant, metadata)
43
+ else
44
+ checkpoint.run_callbacks(:on_convert, self, variant, participant, metadata)
45
+ end
46
+ variant
47
+ rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e
48
+ run_callbacks(:on_redis_failover, e)
49
+ return false
50
+ end
51
+
52
+ def allow_conversion?(variant, checkpoint=nil, metadata=nil)
53
+ if checkpoint.nil?
54
+ return true if callbacks[:allow_conversion].empty?
55
+ # TODO why pass checkpoint through here if checkpoints are handled by their own method? it will always be nil here given current logic
56
+ run_callbacks(:allow_conversion, true, checkpoint, variant, participant, metadata)
57
+ else
58
+ checkpoint.allow_conversion?(self, variant, metadata)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,94 @@
1
+ module TrailGuide
2
+ module Experiments
3
+ module Enrollment
4
+ def algorithm
5
+ @algorithm ||= self.class.algorithm.new(self)
6
+ end
7
+
8
+ def choose!(override: nil, metadata: nil, **opts)
9
+ return control if TrailGuide.configuration.disabled
10
+
11
+ variant = choose_variant!(override: override, metadata: metadata, **opts)
12
+ run_callbacks(:on_use, variant, participant, metadata)
13
+ variant
14
+ rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e
15
+ run_callbacks(:on_redis_failover, e)
16
+ return variants.find { |var| var == override } || control if override.present?
17
+ return control
18
+ end
19
+
20
+ def choose_variant!(override: nil, excluded: false, metadata: nil)
21
+ return control if TrailGuide.configuration.disabled
22
+
23
+ if override.present?
24
+ variant = variants.find { |var| var == override } || control
25
+ if running? && !is_combined?
26
+ variant.increment_participation! if configuration.track_override
27
+ participant.participating!(variant) if configuration.store_override
28
+ end
29
+ return variant
30
+ end
31
+
32
+ if winner?
33
+ variant = winning_variant
34
+ if track_winner_conversions? && running?
35
+ variant.increment_participation! unless participant.variant == variant
36
+ participant.exit! if participant.participating? && participant.variant != variant
37
+ participant.participating!(variant)
38
+ end
39
+ return variant
40
+ end
41
+
42
+ return control if excluded || stopped?
43
+
44
+ if !started? && start_manually?
45
+ if enable_calibration?
46
+ unless participant.variant == control
47
+ control.increment_participation!
48
+ parent.control.increment_participation! if is_combined?
49
+ end
50
+
51
+ if participant.participating? && participant.variant != control
52
+ participant.exit!
53
+ parent.participant.exit! if is_combined?
54
+ end
55
+
56
+ participant.participating!(control)
57
+ parent.participant.participating!(parent.control) if is_combined?
58
+ end
59
+ return control
60
+ end
61
+
62
+ start! unless started? || scheduled?
63
+ return control unless running?
64
+
65
+ # only re-use the variant for experiments that store participation,
66
+ # all other (i.e. content-based) experiments should re-select and
67
+ # re-assign on enrollment
68
+ if configuration.sticky_assignment? && participant.participating?
69
+ variant = participant.variant
70
+ participant.participating!(variant)
71
+ return variant
72
+ end
73
+
74
+ return control unless is_combined? || TrailGuide.configuration.allow_multiple_experiments == true || !participant.participating_in_active_experiments?(TrailGuide.configuration.allow_multiple_experiments == false)
75
+ return control unless allow_participation?(metadata)
76
+
77
+ variant = algorithm_choose!(metadata: metadata)
78
+ variant.increment_participation!
79
+ participant.participating!(variant)
80
+ run_callbacks(:on_choose, variant, participant, metadata)
81
+ variant
82
+ end
83
+
84
+ def algorithm_choose!(metadata: nil)
85
+ algorithm.choose!(metadata: metadata)
86
+ end
87
+
88
+ def allow_participation?(metadata=nil)
89
+ return true if callbacks[:allow_participation].empty?
90
+ run_callbacks(:allow_participation, true, participant, metadata)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,199 @@
1
+ module TrailGuide
2
+ module Experiments
3
+ module Lifecycle
4
+
5
+ def self.included(base)
6
+ base.send :extend, ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def start!(context=nil)
11
+ return false if started?
12
+ save! unless persisted?
13
+ started = adapter.set(:started_at, Time.now.to_i)
14
+ run_callbacks(:on_start, context)
15
+ started
16
+ end
17
+
18
+ def schedule!(start_at, stop_at=nil, context=nil)
19
+ return false if started?
20
+ save! unless persisted?
21
+ scheduled = adapter.set(:started_at, start_at.to_i)
22
+ adapter.set(:stopped_at, stop_at.to_i) if stop_at
23
+ run_callbacks(:on_schedule, start_at, stop_at, context)
24
+ scheduled
25
+ end
26
+
27
+ def pause!(context=nil)
28
+ return false unless running? && configuration.can_resume?
29
+ paused = adapter.set(:paused_at, Time.now.to_i)
30
+ run_callbacks(:on_pause, context)
31
+ paused
32
+ end
33
+
34
+ def stop!(context=nil)
35
+ return false unless started? && !stopped?
36
+ stopped = adapter.set(:stopped_at, Time.now.to_i)
37
+ run_callbacks(:on_stop, context)
38
+ stopped
39
+ end
40
+
41
+ def resume!(context=nil)
42
+ return false unless paused? && configuration.can_resume?
43
+ resumed = adapter.delete(:paused_at)
44
+ run_callbacks(:on_resume, context)
45
+ !!resumed
46
+ end
47
+
48
+ def started_at
49
+ started = adapter.get(:started_at)
50
+ return Time.at(started.to_i) if started
51
+ end
52
+
53
+ def paused_at
54
+ paused = adapter.get(:paused_at)
55
+ return Time.at(paused.to_i) if paused
56
+ end
57
+
58
+ def stopped_at
59
+ stopped = adapter.get(:stopped_at)
60
+ return Time.at(stopped.to_i) if stopped
61
+ end
62
+
63
+ def started?
64
+ time = started_at
65
+ time && time <= Time.now
66
+ end
67
+
68
+ def scheduled?
69
+ time = started_at
70
+ time && time > Time.now
71
+ end
72
+
73
+ def paused?
74
+ time = paused_at
75
+ time && time <= Time.now
76
+ end
77
+
78
+ def stopped?
79
+ time = stopped_at
80
+ time && time <= Time.now
81
+ end
82
+
83
+ def running?
84
+ started? && !paused? && !stopped?
85
+ end
86
+
87
+ def calibrating?
88
+ enable_calibration? && start_manually? && !started?
89
+ end
90
+
91
+ def fresh?
92
+ !started? && !scheduled? && !winner?
93
+ end
94
+
95
+ def declare_winner!(variant, context=nil)
96
+ variant = variants.find { |var| var == variant } unless variant.is_a?(Variant)
97
+ return false unless variant.present? && variant.experiment == self
98
+ run_callbacks(:on_winner, variant, context)
99
+ adapter.set(:winner, variant.name)
100
+ variant
101
+ end
102
+
103
+ def clear_winner!
104
+ adapter.delete(:winner)
105
+ end
106
+
107
+ def winner?
108
+ if combined?
109
+ combined.all? { |combo| TrailGuide.catalog.find(combo).winner? }
110
+ else
111
+ adapter.exists?(:winner)
112
+ end
113
+ end
114
+
115
+ def run_callbacks(hook, *args)
116
+ return unless callbacks[hook]
117
+ return args[0] if hook == :rollout_winner # TODO do we need to account for this case here at the class level?
118
+ args.unshift(self)
119
+ callbacks[hook].each do |callback|
120
+ if callback.respond_to?(:call)
121
+ callback.call(*args)
122
+ else
123
+ send(callback, *args)
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ def run_callbacks(hook, *args)
130
+ return unless callbacks[hook]
131
+ if [:allow_participation, :allow_conversion, :rollout_winner].include?(hook)
132
+ callbacks[hook].reduce(args.slice!(0,1)[0]) do |result, callback|
133
+ if callback.respond_to?(:call)
134
+ callback.call(self, result, *args)
135
+ else
136
+ send(callback, self, result, *args)
137
+ end
138
+ end
139
+ else
140
+ args.unshift(self)
141
+ callbacks[hook].each do |callback|
142
+ if callback.respond_to?(:call)
143
+ callback.call(*args)
144
+ else
145
+ send(callback, *args)
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ def start!
152
+ @started_at = nil
153
+ self.class.start!
154
+ end
155
+
156
+ def started_at
157
+ @started_at ||= self.class.started_at
158
+ end
159
+
160
+ def paused_at
161
+ @paused_at ||= self.class.paused_at
162
+ end
163
+
164
+ def stopped_at
165
+ @stopped_at ||= self.class.stopped_at
166
+ end
167
+
168
+ def scheduled?
169
+ started_at && started_at > Time.now
170
+ end
171
+
172
+ def started?
173
+ started_at && started_at <= Time.now
174
+ end
175
+
176
+ def paused?
177
+ paused_at && paused_at <= Time.now
178
+ end
179
+
180
+ def stopped?
181
+ stopped_at && stopped_at <= Time.now
182
+ end
183
+
184
+ def running?
185
+ started? && !paused? && !stopped?
186
+ end
187
+
188
+ def calibrating?
189
+ enable_calibration? && start_manually? && !started?
190
+ end
191
+
192
+ def winner?
193
+ return @has_winner unless @has_winner.nil?
194
+ @has_winner = self.class.winner?
195
+ end
196
+
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,45 @@
1
+ module TrailGuide
2
+ module Experiments
3
+ module Persistence
4
+
5
+ def self.included(base)
6
+ base.send :extend, ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def adapter
11
+ @adapter ||= TrailGuide::Adapters::Experiments::Redis.new(self)
12
+ end
13
+
14
+ def persisted?
15
+ adapter.persisted?
16
+ end
17
+
18
+ def save!
19
+ combined_experiments.each(&:save!)
20
+ variants.each(&:save!)
21
+ adapter.setnx(:name, experiment_name)
22
+ end
23
+
24
+ def delete!(context=nil)
25
+ combined.each { |combo| TrailGuide.catalog.find(combo).delete! }
26
+ variants.each(&:delete!)
27
+ deleted = adapter.destroy
28
+ run_callbacks(:on_delete, context)
29
+ true
30
+ end
31
+
32
+ def reset!(context=nil)
33
+ delete!(context)
34
+ save!
35
+ run_callbacks(:on_reset, context)
36
+ true
37
+ end
38
+
39
+ def storage_key
40
+ configuration.name
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ module TrailGuide
2
+ module Experiments
3
+ module Results
4
+
5
+ def self.included(base)
6
+ base.send :extend, ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def winner
11
+ winner = adapter.get(:winner)
12
+ return variants.find { |var| var == winner } if winner
13
+ end
14
+
15
+ def participants
16
+ variants.sum(&:participants)
17
+ end
18
+
19
+ def converted(checkpoint=nil)
20
+ variants.sum { |var| var.converted(checkpoint) }
21
+ end
22
+
23
+ def unconverted
24
+ participants - converted
25
+ end
26
+
27
+ def target_sample_size_reached?
28
+ return true unless configuration.target_sample_size
29
+ return true if participants >= configuration.target_sample_size
30
+ return false
31
+ end
32
+ end
33
+
34
+ def winner
35
+ @winner ||= self.class.winner
36
+ end
37
+
38
+ def winning_variant
39
+ return nil unless winner?
40
+ run_callbacks(:rollout_winner, winner, participant)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,39 @@
1
+ module TrailGuide
2
+ class Variants
3
+ attr_reader :variants
4
+ alias_method :to_a, :variants
5
+ delegate :each, :map, to: :variants
6
+
7
+ def initialize(*vars)
8
+ @variants = vars.flatten
9
+ end
10
+
11
+ def dup(experiment)
12
+ self.class.new(variants.map { |var| var.dup(experiment) })
13
+ end
14
+
15
+ def control
16
+ variants.find { |var| var.control? }
17
+ end
18
+
19
+ def method_missing(meth, *args, &block)
20
+ variant = variants.find { |var| var == meth }
21
+ return variant if variant.present?
22
+
23
+ if variants.respond_to?(meth, true)
24
+ result = variants.send(meth, *args, &block)
25
+ if result.is_a?(Array)
26
+ return self.class.new(result)
27
+ else
28
+ return result
29
+ end
30
+ end
31
+
32
+ super
33
+ end
34
+
35
+ def respond_to_missing?(meth, include_private=false)
36
+ variants.find { |var| var == meth }.present? || variants.respond_to?(meth, include_private)
37
+ end
38
+ end
39
+ end
@@ -2,7 +2,7 @@ module TrailGuide
2
2
  module Version
3
3
  MAJOR = 0
4
4
  MINOR = 3
5
- PATCH = 0
5
+ PATCH = 1
6
6
  VERSION = "#{MAJOR}.#{MINOR}.#{PATCH}"
7
7
 
8
8
  class << self
data/lib/trailguide.rb CHANGED
@@ -9,6 +9,7 @@ require "trail_guide/metrics"
9
9
  require "trail_guide/calculators"
10
10
  require "trail_guide/participant"
11
11
  require "trail_guide/variant"
12
+ require "trail_guide/variants"
12
13
  require "trail_guide/experiment"
13
14
  require "trail_guide/combined_experiment"
14
15
  require "trail_guide/catalog"
@@ -23,6 +24,6 @@ module TrailGuide
23
24
  SCHEDULE_DATE_FORMAT = "%m/%d/%Y %I:%M %p %Z"
24
25
 
25
26
  class << self
26
- delegate :logger, :redis, to: :configuration
27
+ delegate :logger, :redis, :redis_client, to: :configuration
27
28
  end
28
29
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trailguide
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mark Rebec
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-08-27 00:00:00.000000000 Z
11
+ date: 2020-04-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -269,7 +269,12 @@ files:
269
269
  - lib/trail_guide/experiments/base.rb
270
270
  - lib/trail_guide/experiments/combined_config.rb
271
271
  - lib/trail_guide/experiments/config.rb
272
+ - lib/trail_guide/experiments/conversion.rb
273
+ - lib/trail_guide/experiments/enrollment.rb
274
+ - lib/trail_guide/experiments/lifecycle.rb
272
275
  - lib/trail_guide/experiments/participant.rb
276
+ - lib/trail_guide/experiments/persistence.rb
277
+ - lib/trail_guide/experiments/results.rb
273
278
  - lib/trail_guide/helper.rb
274
279
  - lib/trail_guide/helper/experiment_proxy.rb
275
280
  - lib/trail_guide/helper/helper_proxy.rb
@@ -282,6 +287,7 @@ files:
282
287
  - lib/trail_guide/spec_helper.rb
283
288
  - lib/trail_guide/unity.rb
284
289
  - lib/trail_guide/variant.rb
290
+ - lib/trail_guide/variants.rb
285
291
  - lib/trail_guide/version.rb
286
292
  - lib/trailguide.rb
287
293
  homepage: https://github.com/markrebec/trailguide
@@ -303,8 +309,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
303
309
  - !ruby/object:Gem::Version
304
310
  version: '0'
305
311
  requirements: []
306
- rubyforge_project:
307
- rubygems_version: 2.7.6
312
+ rubygems_version: 3.0.6
308
313
  signing_key:
309
314
  specification_version: 4
310
315
  summary: User experiments for rails