bandido 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. data/.gitignore +5 -0
  2. data/Gemfile +4 -0
  3. data/LICENSE +674 -0
  4. data/README.rdoc +77 -0
  5. data/Rakefile +34 -0
  6. data/bandit.gemspec +22 -0
  7. data/lib/bandit/config.rb +33 -0
  8. data/lib/bandit/date_hour.rb +82 -0
  9. data/lib/bandit/exceptions.rb +10 -0
  10. data/lib/bandit/experiment.rb +63 -0
  11. data/lib/bandit/extensions/array.rb +3 -0
  12. data/lib/bandit/extensions/controller_concerns.rb +33 -0
  13. data/lib/bandit/extensions/string.rb +5 -0
  14. data/lib/bandit/extensions/time.rb +5 -0
  15. data/lib/bandit/extensions/view_concerns.rb +40 -0
  16. data/lib/bandit/memoizable.rb +32 -0
  17. data/lib/bandit/players/base.rb +37 -0
  18. data/lib/bandit/players/epsilon_greedy.rb +32 -0
  19. data/lib/bandit/players/round_robin.rb +7 -0
  20. data/lib/bandit/storage/base.rb +134 -0
  21. data/lib/bandit/storage/memcache.rb +44 -0
  22. data/lib/bandit/storage/memory.rb +31 -0
  23. data/lib/bandit/storage/redis.rb +47 -0
  24. data/lib/bandit/version.rb +3 -0
  25. data/lib/bandit.rb +69 -0
  26. data/lib/generators/bandit/USAGE +3 -0
  27. data/lib/generators/bandit/dashboard_generator.rb +31 -0
  28. data/lib/generators/bandit/install_generator.rb +22 -0
  29. data/lib/generators/bandit/templates/bandit.rake +20 -0
  30. data/lib/generators/bandit/templates/bandit.rb +18 -0
  31. data/lib/generators/bandit/templates/bandit.yml +16 -0
  32. data/lib/generators/bandit/templates/bandit_controller.rb +30 -0
  33. data/lib/generators/bandit/templates/dashboard/bandit.html.erb +43 -0
  34. data/lib/generators/bandit/templates/dashboard/css/application.css +7 -0
  35. data/lib/generators/bandit/templates/dashboard/css/base.css +28 -0
  36. data/lib/generators/bandit/templates/dashboard/css/toupee/buttons.css +101 -0
  37. data/lib/generators/bandit/templates/dashboard/css/toupee/forms.css +89 -0
  38. data/lib/generators/bandit/templates/dashboard/css/toupee/modules.css +30 -0
  39. data/lib/generators/bandit/templates/dashboard/css/toupee/reset.css +42 -0
  40. data/lib/generators/bandit/templates/dashboard/css/toupee/structure.css +124 -0
  41. data/lib/generators/bandit/templates/dashboard/css/toupee/typography.css +103 -0
  42. data/lib/generators/bandit/templates/dashboard/helpers/bandit_helper.rb +23 -0
  43. data/lib/generators/bandit/templates/dashboard/js/bandit.js +51 -0
  44. data/lib/generators/bandit/templates/dashboard/js/highstock.js +215 -0
  45. data/lib/generators/bandit/templates/dashboard/js/jquery.min.js +154 -0
  46. data/lib/generators/bandit/templates/dashboard/view/_experiment_table.html.erb +19 -0
  47. data/lib/generators/bandit/templates/dashboard/view/index.html.erb +14 -0
  48. data/lib/generators/bandit/templates/dashboard/view/show.html.erb +19 -0
  49. data/players.rdoc +21 -0
  50. data/test/config.yml +7 -0
  51. data/test/helper.rb +18 -0
  52. data/test/memcache_storage_test.rb +17 -0
  53. data/test/memory_storage_test.rb +16 -0
  54. data/test/redis_storage_test.rb +17 -0
  55. data/test/storage_test_base.rb +54 -0
  56. data/whybandit.rdoc +31 -0
  57. metadata +166 -0
data/README.rdoc ADDED
@@ -0,0 +1,77 @@
1
+ = bandit
2
+
3
+ Bandit is a multi-armed bandit optimization framework for Rails. It provides an alternative to A/B testing in Rails. For background and a comparison with A/B testing, see the whybandit.rdoc document or the blog post here[http://findingscience.com/rails/vanity/statistics/testing/2011/11/12/bandit:-a-b-testing-alternative-for-rails.html].
4
+
5
+ = Installation
6
+ First, add the following to your Gemfile in your Rails 3 app:
7
+
8
+ gem 'bandit'
9
+
10
+ Then, run the following:
11
+
12
+ bundle install
13
+ rails generate bandit:install
14
+
15
+ You can then edit the bandit.yml file in your config directory to set your storage and player parameters. Redis, memcache, and memory storage options are available. Memory storage should only be used for testing.
16
+
17
+ See the file players.rdoc for information about available players.
18
+
19
+ == Configuration
20
+ To set up an experiment, add it either somewhere in your code or in the bandit initializer. Creating an experiment is simple:
21
+
22
+ Bandit::Experiment.create(:click_test) { |exp|
23
+ exp.alternatives = [ 20, 30, 40 ]
24
+ exp.title = "Click Test"
25
+ exp.description = "A test of clicks on purchase page with varying link sizes."
26
+ }
27
+
28
+
29
+ == View
30
+ To get an alternative (per viewer, based on cookies):
31
+
32
+ <%= bandit_choose :click_test %>
33
+
34
+ For instance, in a link:
35
+
36
+ <%= link_to "new purchase", new_purchase_path, :style => "font-size: #{bandit_choose(:click_test)}px;" %>
37
+
38
+ You can force a particular alternative by adding a query parameter named "bandit_<experiment name>" and setting it's value to the alternative you want. For instance, given the above experiment in the configuration example:
39
+
40
+ http://<yourhost>/<path>?bandit_click_test=40
41
+
42
+ will then force the alternative to be "40".
43
+
44
+ == Controller
45
+ To track a conversion in your controller:
46
+
47
+ bandit_convert! :click_test
48
+
49
+ = Dashboard
50
+
51
+ rails generate bandit:dashboard
52
+
53
+ Then, add the following to your config/routes.rb file:
54
+
55
+ resources :bandit
56
+
57
+ To see a dashboard with relevant information, go to:
58
+
59
+ http://<yourhost>/bandit
60
+
61
+ = Tests
62
+ To run tests:
63
+
64
+ rake test_memory
65
+ rake test_memcache
66
+ rake test_redis
67
+
68
+ To produce fake data for the past week, first create an experiment definition. Then, run the following rake task:
69
+
70
+ rake bandit:populate_data[<experiment_name>]
71
+
72
+ For instance, to generate a week's worth of fake data for the click_test above:
73
+
74
+ rake bandit:populate_data[click_test]
75
+
76
+ = Fault Tolerance
77
+ If the storage mechanism fails, then Bandit will automatically switch to in memory storage. It will then check every 5 minutes after that to see if the original storage mechanism is back up. If you have distributed front ends then each front end will continue to optimize (based on the in memory storage), but this optimization will be inefficient compared to shared storage among all front ends.
data/Rakefile ADDED
@@ -0,0 +1,34 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rdoc/task'
3
+ require 'rake/testtask'
4
+
5
+ desc "Create documentation"
6
+ Rake::RDocTask.new("doc") { |rdoc|
7
+ rdoc.title = "bandit - A multi-armed bandit optmization framework for Rails"
8
+ rdoc.rdoc_dir = 'docs'
9
+ rdoc.rdoc_files.include('README.rdoc')
10
+ rdoc.rdoc_files.include('players.rdoc')
11
+ rdoc.rdoc_files.include('whybandit.rdoc')
12
+ rdoc.rdoc_files.include('lib/**/*.rb')
13
+ }
14
+
15
+ desc "Run all unit tests with memory storage"
16
+ Rake::TestTask.new("test_memory") { |t|
17
+ t.libs << "lib"
18
+ t.test_files = FileList['test/memory_*.rb']
19
+ t.verbose = true
20
+ }
21
+
22
+ desc "Run all unit tests with memcache storage"
23
+ Rake::TestTask.new("test_memcache") { |t|
24
+ t.libs << "lib"
25
+ t.test_files = FileList['test/memcache_*.rb']
26
+ t.verbose = true
27
+ }
28
+
29
+ desc "Run all unit tests with redis storage"
30
+ Rake::TestTask.new("test_redis") { |t|
31
+ t.libs << "lib"
32
+ t.test_files = FileList['test/redis_*.rb']
33
+ t.verbose = true
34
+ }
data/bandit.gemspec ADDED
@@ -0,0 +1,22 @@
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+ require "bandit/version"
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "bandit"
6
+ s.version = Bandit::VERSION
7
+ s.authors = ["Brian Muller"]
8
+ s.email = ["brian.muller@livingsocial.com"]
9
+ s.homepage = "https://github.com/bmuller/bandit"
10
+ s.summary = "Multi-armed bandit testing in rails"
11
+ s.description = "Bandit provides a way to do multi-armed bandit optimization of alternatives in a rails website"
12
+
13
+ s.rubyforge_project = "bandit"
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
17
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
18
+ s.require_paths = ["lib"]
19
+ s.add_dependency("rails", ">= 3.0.5")
20
+ s.add_dependency("redis", "= 2.2.2")
21
+ s.add_development_dependency('rdoc')
22
+ end
@@ -0,0 +1,33 @@
1
+ module Bandit
2
+
3
+ class Config
4
+ def self.required_fields
5
+ [:storage, :player]
6
+ end
7
+
8
+ # storage should be name of storage engine
9
+ attr_accessor :storage
10
+
11
+ # storage_config should be hash of storage config values
12
+ attr_accessor :storage_config
13
+
14
+ # player should be name of player
15
+ attr_accessor :player
16
+
17
+ # player_config should be hash of player config values
18
+ attr_accessor :player_config
19
+
20
+ def check!
21
+ self.class.required_fields.each do |required_field|
22
+ unless send(required_field)
23
+ raise MissingConfigurationError, "#{required_field} must be set"
24
+ end
25
+ end
26
+
27
+ @storage_config ||= {}
28
+ @player_config ||= {}
29
+ end
30
+
31
+ end
32
+
33
+ end
@@ -0,0 +1,82 @@
1
+ require 'date'
2
+
3
+ module Bandit
4
+
5
+ class DateHour
6
+ attr_accessor :date, :hour
7
+
8
+ def initialize(date, hour)
9
+ @date = date
10
+ @hour = hour
11
+ end
12
+
13
+ def self.now
14
+ DateHour.new Date.today, Time.now.hour
15
+ end
16
+
17
+ def upto(other)
18
+ n = DateHour.new(@date, @hour)
19
+ while n <= other
20
+ yield n
21
+ n += 1
22
+ end
23
+ end
24
+
25
+ # given a block that is called w/ each DateHour for a day, inject over all DateHours
26
+ def self.date_inject(date, initial=0)
27
+ DateHour.new(date, 0).upto(DateHour.new(date, 23)) { |dh|
28
+ initial = yield initial, dh
29
+ }
30
+ initial
31
+ end
32
+
33
+ def +(hours)
34
+ (to_time + (hours * 3600)).to_date_hour
35
+ end
36
+
37
+ def ==(other)
38
+ @date == other.date and @hour == other.hour
39
+ end
40
+
41
+ def <(other)
42
+ @date < other.date or (@date == other.date and @hour < other.hour)
43
+ end
44
+
45
+ def >(other)
46
+ @date > other.date or (@date == other.date and @hour > other.hour)
47
+ end
48
+
49
+ def >=(other)
50
+ self > other or self == other
51
+ end
52
+
53
+ def <=(other)
54
+ self < other or self == other
55
+ end
56
+
57
+ def to_time
58
+ Time.mktime year, month, day, hour, 0
59
+ end
60
+
61
+ def to_s
62
+ "#{@date.to_s} #{@hour}:00:00"
63
+ end
64
+
65
+ def to_i
66
+ to_time.to_i
67
+ end
68
+
69
+ def year
70
+ @date.year
71
+ end
72
+
73
+ def month
74
+ @date.month
75
+ end
76
+
77
+ def day
78
+ @date.day
79
+ end
80
+ end
81
+
82
+ end
@@ -0,0 +1,10 @@
1
+ module Bandit
2
+ class UnknownStorageEngineError < RuntimeError
3
+ end
4
+
5
+ class UnknownPlayerEngineError < RuntimeError
6
+ end
7
+
8
+ class MissingConfigurationError < ArgumentError
9
+ end
10
+ end
@@ -0,0 +1,63 @@
1
+ module Bandit
2
+ class Experiment
3
+ attr_accessor :name, :title, :description, :alternatives
4
+ @@instances = []
5
+
6
+ def self.create(name)
7
+ e = Experiment.new(:name => name)
8
+ yield e
9
+ e.validate!
10
+ e
11
+ end
12
+
13
+ def initialize(args=nil)
14
+ args.each { |k,v| send "#{k}=", v } unless args.nil?
15
+ @@instances << self
16
+ @storage = Bandit.storage
17
+ end
18
+
19
+ def self.instances
20
+ @@instances
21
+ end
22
+
23
+ def choose(default=nil)
24
+ if default && alternatives.include?(default)
25
+ alt = default
26
+ else
27
+ alt = Bandit.player.choose_alternative(self)
28
+ @storage.incr_participants(self, alt)
29
+ end
30
+ alt
31
+ end
32
+
33
+ def convert!(alt, count=1)
34
+ @storage.incr_conversions(self, alt, count)
35
+ end
36
+
37
+ def validate!
38
+ [:title, :alternatives].each { |field|
39
+ unless send(field)
40
+ raise MissingConfigurationError, "#{field} must be set in experiment #{name}"
41
+ end
42
+ }
43
+ end
44
+
45
+ def conversion_count(alt, date_hour=nil)
46
+ @storage.conversion_count(self, alt, date_hour)
47
+ end
48
+
49
+ def participant_count(alt, date_hour=nil)
50
+ @storage.participant_count(self, alt, date_hour)
51
+ end
52
+
53
+ def conversion_rate(alt)
54
+ pcount = participant_count(alt)
55
+ ccount = conversion_count(alt)
56
+ (pcount == 0 or ccount == 0) ? 0 : (ccount.to_f / pcount.to_f * 100.0)
57
+ end
58
+
59
+ def alternative_start(alt)
60
+ @storage.alternative_start_time(self, alt)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,3 @@
1
+ class Array
2
+ alias_method :sample, :choice unless method_defined?(:sample)
3
+ end
@@ -0,0 +1,33 @@
1
+ require 'active_support/concern'
2
+
3
+ module Bandit
4
+ module ControllerConcerns
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+
9
+ end
10
+
11
+ module InstanceMethods
12
+ def bandit_convert!(exp, alt=nil, count=1)
13
+ cookiename = "bandit_#{exp}".intern
14
+ alt ||= cookies.signed[cookiename]
15
+ unless alt.nil?
16
+ Bandit.get_experiment(exp).convert!(alt, count)
17
+ cookies.delete(cookiename)
18
+ end
19
+ end
20
+
21
+ def bandit_final_convert!(exp, alt=nil, count=1)
22
+ cookiename = "bandit_#{exp}".intern
23
+ cookiename_converted = "bandit_#{exp}_converted".intern
24
+ alt ||= cookies.signed[cookiename]
25
+ unless alt.nil? or cookies.signed[cookiename_converted]
26
+ cookies.permanent.signed[cookiename_converted] = "true"
27
+ Bandit.get_experiment(exp).convert!(alt, count)
28
+ end
29
+ end
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ class String
2
+ def numeric?
3
+ true if Integer(self) rescue false
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class Time
2
+ def to_date_hour
3
+ Bandit::DateHour.new Date.new(year, month, day), hour
4
+ end
5
+ end
@@ -0,0 +1,40 @@
1
+ require 'active_support/concern'
2
+
3
+ module Bandit
4
+ module ViewConcerns
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+
9
+ end
10
+
11
+ module InstanceMethods
12
+ def bandit_choose(exp)
13
+ name = "bandit_#{exp}".intern
14
+
15
+ # choose url param with preference
16
+ value = params[name].nil? ? cookies.signed[name] : params[name]
17
+
18
+ # choose with default, and set cookie
19
+ cookies.signed[name] = Bandit.get_experiment(exp).choose(value)
20
+ end
21
+
22
+ def bandit_sticky_choose(exp)
23
+ name = "bandit_#{exp}".intern
24
+
25
+ # choose url param with preference
26
+ value = params[name].nil? ? cookies.signed[name] : params[name]
27
+
28
+ # sticky choice may outlast a given alternative
29
+ alternative = if Bandit.get_experiment(exp).alternatives.include?(value)
30
+ value
31
+ else
32
+ Bandit.get_experiment(exp).choose(value)
33
+ end
34
+
35
+ # re-set cookie
36
+ cookies.permanent.signed[name] = alternative
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,32 @@
1
+ module Bandit
2
+ module Memoizable
3
+ # remember a block for some time (60 seconds by default)
4
+ def memoize(key, time=60)
5
+ @memoized ||= {}
6
+ @memoized_times ||= {}
7
+ now = Time.now.to_i
8
+ if not @memoized.has_key?(key) or now > @memoized_times[key]
9
+ @memoized[key] = yield
10
+ @memoized_times[key] = now + time
11
+ end
12
+ @memoized[key]
13
+ end
14
+
15
+ module ClassMethods
16
+ def memoize_method(method, time=60)
17
+ original_method = "unmemoized_#{method}_#{Time.now.to_i}"
18
+ alias_method original_method, method
19
+ module_eval(<<-EVAL, __FILE__, __LINE__)
20
+ def #{method}(*args, &block)
21
+ memoize(:#{original_method}, #{time}) { send(:#{original_method}, *args, &block) }
22
+ end
23
+ EVAL
24
+ end
25
+ end
26
+
27
+ def self.included(base)
28
+ base.extend(ClassMethods)
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ module Bandit
2
+ class BasePlayer
3
+ def self.get_player(name, config)
4
+ config ||= {}
5
+
6
+ case name
7
+ when :round_robin then RoundRobinPlayer.new(config)
8
+ when :epsilon_greedy then EpsilonGreedyPlayer.new(config)
9
+ else raise UnknownPlayerEngineError, "#{name} not a known player type"
10
+ end
11
+ end
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ @storage = Bandit.storage
16
+ end
17
+
18
+ def choose_alternative(experiment)
19
+ # return the alternative that should be chosen
20
+ raise NotImplementedError
21
+ end
22
+
23
+ # store state variable by name
24
+ def set(name, value)
25
+ @storage.player_state_set(self, name, value)
26
+ end
27
+
28
+ # get state variable by name
29
+ def get(name)
30
+ @storage.player_state_get(self, name)
31
+ end
32
+
33
+ def name
34
+ self.class.to_s
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,32 @@
1
+ module Bandit
2
+ class EpsilonGreedyPlayer < BasePlayer
3
+ include Memoizable
4
+
5
+ def choose_alternative(experiment)
6
+ epsilon = @config['epsilon'].to_f || 0.1
7
+
8
+ # choose best with probability of 1-epsilon
9
+ if rand <= (1-epsilon)
10
+ best_alternative(experiment)
11
+ else
12
+ experiment.alternatives.sample
13
+ end
14
+ end
15
+
16
+ def best_alternative(experiment)
17
+ memoize(experiment.name) {
18
+ best = nil
19
+ best_rate = nil
20
+ experiment.alternatives.each { |alt|
21
+ rate = experiment.conversion_rate(alt)
22
+ if best_rate.nil? or rate > best_rate
23
+ best = alt
24
+ best_rate = rate
25
+ end
26
+ }
27
+ best
28
+ }
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,7 @@
1
+ module Bandit
2
+ class RoundRobinPlayer < BasePlayer
3
+ def choose_alternative(experiment)
4
+ experiment.alternatives.sample
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,134 @@
1
+ # store total count for this alternative
2
+ # conversions:<experiment>:<alternative> = count
3
+ # participants:<experiment>:<alternative> = count
4
+
5
+ # store first time an alternative is used
6
+ # altstart:<experiment>:<alternative> = timestamp
7
+
8
+ # store total count for this alternative per day and hour
9
+ # conversions:<experiment>:<alternative>:<date>:<hour> = count
10
+ # participants:<experiment>:<alternative>:<date>:<hour> = count
11
+
12
+ # every so often store current epsilon
13
+ # state:<experiment>:<player>:epsilon = 0.1
14
+
15
+ module Bandit
16
+ class BaseStorage
17
+ def self.get_storage(name, config)
18
+ config ||= {}
19
+
20
+ case name
21
+ when :memory then MemoryStorage.new(config)
22
+ when :memcache then MemCacheStorage.new(config)
23
+ when :redis then RedisStorage.new(config)
24
+ else raise UnknownStorageEngineError, "#{name} not a known storage method"
25
+ end
26
+ end
27
+
28
+ # increment key by count
29
+ def incr(key, count)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # initialize key if not set
34
+ def init(key, value)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ # get key if exists, otherwise 0
39
+ def get(key, default=0)
40
+ raise NotImplementedError
41
+ end
42
+
43
+ # set key with value, regardless of whether it is set or not
44
+ def set(key, value)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ # clear all stored values
49
+ def clear!
50
+ raise NotImplementedError
51
+ end
52
+
53
+ def incr_participants(experiment, alternative, count=1, date_hour=nil)
54
+ date_hour ||= DateHour.now
55
+
56
+ # initialize first start time for alternative if we haven't inited yet
57
+ init alt_started_key(experiment, alternative), date_hour.to_i
58
+
59
+ # increment total count and per hour count
60
+ incr part_key(experiment, alternative), count
61
+ incr part_key(experiment, alternative, date_hour), count
62
+ end
63
+
64
+ def incr_conversions(experiment, alternative, count=1, date_hour=nil)
65
+ # increment total count and per hour count
66
+ incr conv_key(experiment, alternative), count
67
+ incr conv_key(experiment, alternative, date_hour || DateHour.now), count
68
+ end
69
+
70
+ # if date_hour isn't specified, get total count
71
+ # if date_hour is specified, return count for DateHour
72
+ def participant_count(experiment, alternative, date_hour=nil)
73
+ get part_key(experiment, alternative, date_hour)
74
+ end
75
+
76
+ # if date_hour isn't specified, get total count
77
+ # if date_hour is specified, return count for DateHour
78
+ def conversion_count(experiment, alternative, date_hour=nil)
79
+ get conv_key(experiment, alternative, date_hour)
80
+ end
81
+
82
+ def player_state_set(experiment, player, name, value)
83
+ set player_state_key(experiment, player, name), value
84
+ end
85
+
86
+ def player_state_get(experiment, player, name)
87
+ get player_state_key(experiment, player, name), nil
88
+ end
89
+
90
+ def alternative_start_time(experiment, alternative)
91
+ secs = get alt_started_key(experiment, alternative), nil
92
+ secs.nil? ? nil : Time.at(secs).to_date_hour
93
+ end
94
+
95
+ # if date_hour is nil, create key for total
96
+ # otherwise, create key for hourly based
97
+ def part_key(exp, alt, date_hour=nil)
98
+ parts = [ "participants", exp.name, alt ]
99
+ parts += [ date_hour.date, date_hour.hour ] unless date_hour.nil?
100
+ make_key parts
101
+ end
102
+
103
+ # key for alternative start
104
+ def alt_started_key(experiment, alternative)
105
+ make_key [ "altstarted", experiment.name, alternative ]
106
+ end
107
+
108
+ # if date_hour is nil, create key for total
109
+ # otherwise, create key for hourly based
110
+ def conv_key(exp, alt, date_hour=nil)
111
+ parts = [ "conversions", exp.name, alt ]
112
+ parts += [ date_hour.date, date_hour.hour ] unless date_hour.nil?
113
+ make_key parts
114
+ end
115
+
116
+ def player_state_key(exp, player, varname)
117
+ make_key [ "state", exp.name, player.name, varname ]
118
+ end
119
+
120
+ def make_key(parts)
121
+ parts.join(":")
122
+ end
123
+
124
+ def with_failure_grace(fail_default=0)
125
+ begin
126
+ yield
127
+ rescue
128
+ Bandit.storage_failed!
129
+ Rails.logger.error "Storage method #{self.class} failed. Falling back to memory storage."
130
+ fail_default
131
+ end
132
+ end
133
+ end
134
+ end