ab-split 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +30 -0
  3. data/.csslintrc +2 -0
  4. data/.eslintignore +1 -0
  5. data/.eslintrc +213 -0
  6. data/.github/FUNDING.yml +1 -0
  7. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  8. data/.rspec +1 -0
  9. data/.rubocop.yml +7 -0
  10. data/.rubocop_todo.yml +679 -0
  11. data/.travis.yml +60 -0
  12. data/Appraisals +19 -0
  13. data/CHANGELOG.md +696 -0
  14. data/CODE_OF_CONDUCT.md +74 -0
  15. data/CONTRIBUTING.md +62 -0
  16. data/Gemfile +7 -0
  17. data/LICENSE +22 -0
  18. data/README.md +955 -0
  19. data/Rakefile +9 -0
  20. data/ab-split.gemspec +44 -0
  21. data/gemfiles/4.2.gemfile +9 -0
  22. data/gemfiles/5.0.gemfile +9 -0
  23. data/gemfiles/5.1.gemfile +9 -0
  24. data/gemfiles/5.2.gemfile +9 -0
  25. data/gemfiles/6.0.gemfile +9 -0
  26. data/lib/split.rb +76 -0
  27. data/lib/split/algorithms/block_randomization.rb +23 -0
  28. data/lib/split/algorithms/weighted_sample.rb +18 -0
  29. data/lib/split/algorithms/whiplash.rb +38 -0
  30. data/lib/split/alternative.rb +191 -0
  31. data/lib/split/combined_experiments_helper.rb +37 -0
  32. data/lib/split/configuration.rb +255 -0
  33. data/lib/split/dashboard.rb +74 -0
  34. data/lib/split/dashboard/helpers.rb +45 -0
  35. data/lib/split/dashboard/pagination_helpers.rb +86 -0
  36. data/lib/split/dashboard/paginator.rb +16 -0
  37. data/lib/split/dashboard/public/dashboard-filtering.js +43 -0
  38. data/lib/split/dashboard/public/dashboard.js +24 -0
  39. data/lib/split/dashboard/public/jquery-1.11.1.min.js +4 -0
  40. data/lib/split/dashboard/public/reset.css +48 -0
  41. data/lib/split/dashboard/public/style.css +328 -0
  42. data/lib/split/dashboard/views/_controls.erb +18 -0
  43. data/lib/split/dashboard/views/_experiment.erb +155 -0
  44. data/lib/split/dashboard/views/_experiment_with_goal_header.erb +8 -0
  45. data/lib/split/dashboard/views/index.erb +26 -0
  46. data/lib/split/dashboard/views/layout.erb +27 -0
  47. data/lib/split/encapsulated_helper.rb +42 -0
  48. data/lib/split/engine.rb +15 -0
  49. data/lib/split/exceptions.rb +6 -0
  50. data/lib/split/experiment.rb +486 -0
  51. data/lib/split/experiment_catalog.rb +51 -0
  52. data/lib/split/extensions/string.rb +16 -0
  53. data/lib/split/goals_collection.rb +45 -0
  54. data/lib/split/helper.rb +165 -0
  55. data/lib/split/metric.rb +101 -0
  56. data/lib/split/persistence.rb +28 -0
  57. data/lib/split/persistence/cookie_adapter.rb +94 -0
  58. data/lib/split/persistence/dual_adapter.rb +85 -0
  59. data/lib/split/persistence/redis_adapter.rb +57 -0
  60. data/lib/split/persistence/session_adapter.rb +29 -0
  61. data/lib/split/redis_interface.rb +50 -0
  62. data/lib/split/trial.rb +117 -0
  63. data/lib/split/user.rb +69 -0
  64. data/lib/split/version.rb +7 -0
  65. data/lib/split/zscore.rb +57 -0
  66. data/spec/algorithms/block_randomization_spec.rb +32 -0
  67. data/spec/algorithms/weighted_sample_spec.rb +19 -0
  68. data/spec/algorithms/whiplash_spec.rb +24 -0
  69. data/spec/alternative_spec.rb +320 -0
  70. data/spec/combined_experiments_helper_spec.rb +57 -0
  71. data/spec/configuration_spec.rb +258 -0
  72. data/spec/dashboard/pagination_helpers_spec.rb +200 -0
  73. data/spec/dashboard/paginator_spec.rb +37 -0
  74. data/spec/dashboard_helpers_spec.rb +42 -0
  75. data/spec/dashboard_spec.rb +210 -0
  76. data/spec/encapsulated_helper_spec.rb +52 -0
  77. data/spec/experiment_catalog_spec.rb +53 -0
  78. data/spec/experiment_spec.rb +533 -0
  79. data/spec/goals_collection_spec.rb +80 -0
  80. data/spec/helper_spec.rb +1111 -0
  81. data/spec/metric_spec.rb +31 -0
  82. data/spec/persistence/cookie_adapter_spec.rb +106 -0
  83. data/spec/persistence/dual_adapter_spec.rb +194 -0
  84. data/spec/persistence/redis_adapter_spec.rb +90 -0
  85. data/spec/persistence/session_adapter_spec.rb +32 -0
  86. data/spec/persistence_spec.rb +34 -0
  87. data/spec/redis_interface_spec.rb +111 -0
  88. data/spec/spec_helper.rb +52 -0
  89. data/spec/split_spec.rb +43 -0
  90. data/spec/support/cookies_mock.rb +20 -0
  91. data/spec/trial_spec.rb +299 -0
  92. data/spec/user_spec.rb +87 -0
  93. metadata +322 -0
@@ -0,0 +1,18 @@
1
+ <% if experiment.has_winner? %>
2
+ <form action="<%= url "/reopen?experiment=#{experiment.name}" %>" method='post' onclick="return confirmReopen()">
3
+ <input type="submit" value="Reopen Experiment">
4
+ </form>
5
+ <% end %>
6
+ <% if experiment.start_time %>
7
+ <form action="<%= url "/reset?experiment=#{experiment.name}" %>" method='post' onclick="return confirmReset()">
8
+ <input type="submit" value="Reset Data">
9
+ </form>
10
+ <% else%>
11
+ <form action="<%= url "/start?experiment=#{experiment.name}" %>" method='post'>
12
+ <input type="submit" value="Start">
13
+ </form>
14
+ <% end %>
15
+ <form action="<%= url "/experiment?experiment=#{experiment.name}" %>" method='post' onclick="return confirmDelete()">
16
+ <input type="hidden" name="_method" value="delete"/>
17
+ <input type="submit" value="Delete" class="red">
18
+ </form>
@@ -0,0 +1,155 @@
1
+ <% unless goal.nil? %>
2
+ <% experiment_class = "experiment experiment_with_goal" %>
3
+ <% else %>
4
+ <% experiment_class = "experiment" %>
5
+ <% end %>
6
+
7
+ <% experiment.calc_winning_alternatives %>
8
+ <%
9
+ extra_columns = []
10
+ experiment.alternatives.each do |alternative|
11
+ extra_info = alternative.extra_info || {}
12
+ extra_columns += extra_info.keys
13
+ end
14
+
15
+ extra_columns.uniq!
16
+ summary_texts = {}
17
+ extra_columns.each do |column|
18
+ extra_infos = experiment.alternatives.map(&:extra_info).select{|extra_info| extra_info && extra_info[column] }
19
+ if extra_infos[0][column].kind_of?(Numeric)
20
+ summary_texts[column] = extra_infos.inject(0){|sum, extra_info| sum += extra_info[column]}
21
+ else
22
+ summary_texts[column] = "N/A"
23
+ end
24
+ end
25
+ %>
26
+
27
+
28
+ <div class="<%= experiment_class %>" data-name="<%= experiment.name %>" data-complete="<%= experiment.has_winner? %>">
29
+ <div class="experiment-header">
30
+ <h2>
31
+ Experiment: <%= experiment.name %>
32
+ <% if experiment.version > 1 %><span class='version'>v<%= experiment.version %></span><% end %>
33
+ <% unless goal.nil? %><span class='goal'>Goal:<%= goal %></span><% end %>
34
+ <% metrics = @metrics.select {|metric| metric.experiments.include? experiment} %>
35
+ <% unless metrics.empty? %>
36
+ <span class='goal'>Metrics:<%= metrics.map(&:name).join(', ') %></span>
37
+ <% end %>
38
+ </h2>
39
+
40
+ <% if goal.nil? %>
41
+ <div class='inline-controls'>
42
+ <small><%= experiment.start_time ? experiment.start_time.strftime('%Y-%m-%d') : 'Unknown' %></small>
43
+ <%= erb :_controls, :locals => {:experiment => experiment} %>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+ <table>
48
+ <tr>
49
+ <th>Alternative Name</th>
50
+ <th>Participants</th>
51
+ <th>Non-finished</th>
52
+ <th>Completed</th>
53
+ <th>Conversion Rate</th>
54
+ <% extra_columns.each do |column| %>
55
+ <th><%= column %></th>
56
+ <% end %>
57
+ <th>
58
+ <form>
59
+ <select id="dropdown-<%=experiment.jstring(goal)%>" name="dropdown-<%=experiment.jstring(goal)%>">
60
+ <option value="confidence-<%=experiment.jstring(goal)%>">Confidence</option>
61
+ <option value="probability-<%=experiment.jstring(goal)%>">Probability of being Winner</option>
62
+ </select>
63
+ </form>
64
+ </th>
65
+ <th>Finish</th>
66
+ </tr>
67
+
68
+ <% total_participants = total_completed = total_unfinished = 0 %>
69
+ <% experiment.alternatives.each do |alternative| %>
70
+ <tr>
71
+ <td>
72
+ <%= alternative.name %>
73
+ <% if alternative.control? %>
74
+ <em>control</em>
75
+ <% end %>
76
+ <form action="<%= url('force_alternative') + '?experiment=' + experiment.name %>" method='post'>
77
+ <input type='hidden' name='alternative' value='<%= h alternative.name %>'>
78
+ <input type="submit" value="Force for current user" class="green">
79
+ </form>
80
+ </td>
81
+ <td><%= alternative.participant_count %></td>
82
+ <td><%= alternative.unfinished_count %></td>
83
+ <td><%= alternative.completed_count(goal) %></td>
84
+ <td>
85
+ <%= number_to_percentage(alternative.conversion_rate(goal)) %>%
86
+ <% if experiment.control.conversion_rate(goal) > 0 && !alternative.control? %>
87
+ <% if alternative.conversion_rate(goal) > experiment.control.conversion_rate(goal) %>
88
+ <span class='better'>
89
+ +<%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>%
90
+ </span>
91
+ <% elsif alternative.conversion_rate(goal) < experiment.control.conversion_rate(goal) %>
92
+ <span class='worse'>
93
+ <%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>%
94
+ </span>
95
+ <% end %>
96
+ <% end %>
97
+ </td>
98
+ <script type="text/javascript" id="sourcecode">
99
+ $(document).ready(function(){
100
+ $('.probability-<%=experiment.jstring(goal)%>').hide();
101
+ $('#dropdown-<%=experiment.jstring(goal)%>').change(function() {
102
+ $('.box-<%=experiment.jstring(goal)%>').hide();
103
+ $('.' + $(this).val()).show();
104
+ });
105
+ });
106
+ </script>
107
+ <% extra_columns.each do |column| %>
108
+ <td><%= alternative.extra_info && alternative.extra_info[column] %></td>
109
+ <% end %>
110
+ <td>
111
+ <div class="box-<%=experiment.jstring(goal)%> confidence-<%=experiment.jstring(goal)%>">
112
+ <span title='z-score: <%= round(alternative.z_score(goal), 3) %>'><%= confidence_level(alternative.z_score(goal)) %></span>
113
+ <br>
114
+ </div>
115
+ <div class="box-<%=experiment.jstring(goal)%> probability-<%=experiment.jstring(goal)%>">
116
+ <span title="p_winner: <%= round(alternative.p_winner(goal), 3) %>"><%= number_to_percentage(round(alternative.p_winner(goal), 3)) %>%</span>
117
+ </div>
118
+ </td>
119
+ <td>
120
+ <% if experiment.has_winner? %>
121
+ <% if experiment.winner.name == alternative.name %>
122
+ Winner
123
+ <% else %>
124
+ Loser
125
+ <% end %>
126
+ <% else %>
127
+ <form action="<%= url('experiment') + '?experiment=' + experiment.name %>" method='post' onclick="return confirmWinner()">
128
+ <input type='hidden' name='alternative' value='<%= h alternative.name %>'>
129
+ <input type="submit" value="Use this" class="green">
130
+ </form>
131
+ <% end %>
132
+ </td>
133
+ </tr>
134
+
135
+ <% total_participants += alternative.participant_count %>
136
+ <% total_unfinished += alternative.unfinished_count %>
137
+ <% total_completed += alternative.completed_count(goal) %>
138
+ <% end %>
139
+
140
+ <tr class="totals">
141
+ <td>Totals</td>
142
+ <td><%= total_participants %></td>
143
+ <td><%= total_unfinished %></td>
144
+ <td><%= total_completed %></td>
145
+ <td>N/A</td>
146
+ <% extra_columns.each do |column| %>
147
+ <td>
148
+ <%= summary_texts[column] %>
149
+ </td>
150
+ <% end %>
151
+ <td>N/A</td>
152
+ <td>N/A</td>
153
+ </tr>
154
+ </table>
155
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="experiment">
2
+ <div class="experiment-header">
3
+ <div class='inline-controls'>
4
+ <small><%= experiment.start_time ? experiment.start_time.strftime('%Y-%m-%d') : 'Unknown' %></small>
5
+ <%= erb :_controls, :locals => {:experiment => experiment} %>
6
+ </div>
7
+ </div>
8
+ </div>
@@ -0,0 +1,26 @@
1
+ <% if @experiments.any? %>
2
+ <p class="intro">The list below contains all the registered experiments along with the number of test participants, completed and conversion rate currently in the system.</p>
3
+
4
+ <input type="text" placeholder="Begin typing to filter" id="filter" />
5
+ <input type="button" id="toggle-completed" value="Hide completed" />
6
+ <input type="button" id="toggle-active" value="Hide active" />
7
+ <input type="button" id="clear-filter" value="Clear filters" />
8
+
9
+ <% paginated(@experiments).each do |experiment| %>
10
+ <% if experiment.goals.empty? %>
11
+ <%= erb :_experiment, :locals => {:goal => nil, :experiment => experiment} %>
12
+ <% else %>
13
+ <%= erb :_experiment_with_goal_header, :locals => {:experiment => experiment} %>
14
+ <% experiment.goals.each do |g| %>
15
+ <%= erb :_experiment, :locals => {:goal => g, :experiment => experiment} %>
16
+ <% end %>
17
+ <% end %>
18
+ <% end %>
19
+
20
+ <div class="pagination">
21
+ <%= pagination(@experiments) %>
22
+ </div>
23
+ <% else %>
24
+ <p class="intro">No experiments have started yet, you need to define them in your code and introduce them to your users.</p>
25
+ <p class="intro">Check out the <a href='https://github.com/splitrb/split#readme'>Readme</a> for more help getting started.</p>
26
+ <% end %>
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta content='text/html; charset=utf-8' http-equiv='Content-Type'>
5
+ <link href="<%= url 'reset.css' %>" media="screen" rel="stylesheet" type="text/css">
6
+ <link href="<%= url 'style.css' %>" media="screen" rel="stylesheet" type="text/css">
7
+ <script type="text/javascript" src='<%= url 'dashboard.js' %>'></script>
8
+ <script type="text/javascript" src='<%= url 'jquery-1.11.1.min.js' %>'></script>
9
+ <script type="text/javascript" src='<%= url 'dashboard-filtering.js' %>'></script>
10
+ <title>Split</title>
11
+
12
+ </head>
13
+ <body>
14
+ <div class="header">
15
+ <h1>Split Dashboard</h1>
16
+ <p class="environment"><%= @current_env %></p>
17
+ </div>
18
+
19
+ <div id="main">
20
+ <%= yield %>
21
+ </div>
22
+
23
+ <div id="footer">
24
+ <p>Powered by <a href="https://github.com/splitrb/split">Split</a> v<%=Split::VERSION %></p>
25
+ </div>
26
+ </body>
27
+ </html>
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ require "split/helper"
3
+
4
+ # Split's helper exposes all kinds of methods we don't want to
5
+ # mix into our model classes.
6
+ #
7
+ # This module exposes only two methods:
8
+ # - ab_test()
9
+ # - ab_finished()
10
+ # that can safely be mixed into any class.
11
+ #
12
+ # Passes the instance of the class that it's mixed into to the
13
+ # Split persistence adapter as context.
14
+ #
15
+ module Split
16
+ module EncapsulatedHelper
17
+
18
+ class ContextShim
19
+ include Split::Helper
20
+ public :ab_test, :ab_finished
21
+
22
+ def initialize(context)
23
+ @context = context
24
+ end
25
+
26
+ def ab_user
27
+ @ab_user ||= Split::User.new(@context)
28
+ end
29
+ end
30
+
31
+ def ab_test(*arguments,&block)
32
+ split_context_shim.ab_test(*arguments,&block)
33
+ end
34
+
35
+ private
36
+
37
+ # instantiate and memoize a context shim in case of multiple ab_test* calls
38
+ def split_context_shim
39
+ @split_context_shim ||= ContextShim.new(self)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class Engine < ::Rails::Engine
4
+ initializer "split" do |app|
5
+ if Split.configuration.include_rails_helper
6
+ ActiveSupport.on_load(:action_controller) do
7
+ ::ActionController::Base.send :include, Split::Helper
8
+ ::ActionController::Base.helper Split::Helper
9
+ ::ActionController::Base.send :include, Split::CombinedExperimentsHelper
10
+ ::ActionController::Base.helper Split::CombinedExperimentsHelper
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class InvalidPersistenceAdapterError < StandardError; end
4
+ class ExperimentNotFound < StandardError; end
5
+ class InvalidExperimentsFormatError < StandardError; end
6
+ end
@@ -0,0 +1,486 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class Experiment
4
+ attr_accessor :name
5
+ attr_accessor :goals
6
+ attr_accessor :alternative_probabilities
7
+ attr_accessor :metadata
8
+
9
+ attr_reader :alternatives
10
+ attr_reader :resettable
11
+
12
+ DEFAULT_OPTIONS = {
13
+ :resettable => true
14
+ }
15
+
16
+ def initialize(name, options = {})
17
+ options = DEFAULT_OPTIONS.merge(options)
18
+
19
+ @name = name.to_s
20
+
21
+ alternatives = extract_alternatives_from_options(options)
22
+
23
+ if alternatives.empty? && (exp_config = Split.configuration.experiment_for(name))
24
+ options = {
25
+ alternatives: load_alternatives_from_configuration,
26
+ goals: Split::GoalsCollection.new(@name).load_from_configuration,
27
+ metadata: load_metadata_from_configuration,
28
+ resettable: exp_config[:resettable],
29
+ algorithm: exp_config[:algorithm]
30
+ }
31
+ else
32
+ options[:alternatives] = alternatives
33
+ end
34
+
35
+ set_alternatives_and_options(options)
36
+ end
37
+
38
+ def self.finished_key(key)
39
+ "#{key}:finished"
40
+ end
41
+
42
+ def set_alternatives_and_options(options)
43
+ options_with_defaults = DEFAULT_OPTIONS.merge(
44
+ options.reject { |k, v| v.nil? }
45
+ )
46
+
47
+ self.alternatives = options_with_defaults[:alternatives]
48
+ self.goals = options_with_defaults[:goals]
49
+ self.resettable = options_with_defaults[:resettable]
50
+ self.algorithm = options_with_defaults[:algorithm]
51
+ self.metadata = options_with_defaults[:metadata]
52
+ end
53
+
54
+ def extract_alternatives_from_options(options)
55
+ alts = options[:alternatives] || []
56
+
57
+ if alts.length == 1
58
+ if alts[0].is_a? Hash
59
+ alts = alts[0].map{|k,v| {k => v} }
60
+ end
61
+ end
62
+
63
+ if alts.empty?
64
+ exp_config = Split.configuration.experiment_for(name)
65
+ if exp_config
66
+ alts = load_alternatives_from_configuration
67
+ options[:goals] = Split::GoalsCollection.new(@name).load_from_configuration
68
+ options[:metadata] = load_metadata_from_configuration
69
+ options[:resettable] = exp_config[:resettable]
70
+ options[:algorithm] = exp_config[:algorithm]
71
+ end
72
+ end
73
+
74
+ options[:alternatives] = alts
75
+
76
+ set_alternatives_and_options(options)
77
+
78
+ # calculate probability that each alternative is the winner
79
+ @alternative_probabilities = {}
80
+ alts
81
+ end
82
+
83
+ def save
84
+ validate!
85
+
86
+ if new_record?
87
+ start unless Split.configuration.start_manually
88
+ persist_experiment_configuration
89
+ elsif experiment_configuration_has_changed?
90
+ reset unless Split.configuration.reset_manually
91
+ persist_experiment_configuration
92
+ end
93
+
94
+ redis.hset(experiment_config_key, :resettable, resettable)
95
+ redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
96
+ self
97
+ end
98
+
99
+ def validate!
100
+ if @alternatives.empty? && Split.configuration.experiment_for(@name).nil?
101
+ raise ExperimentNotFound.new("Experiment #{@name} not found")
102
+ end
103
+ @alternatives.each {|a| a.validate! }
104
+ goals_collection.validate!
105
+ end
106
+
107
+ def new_record?
108
+ !redis.exists(name)
109
+ end
110
+
111
+ def ==(obj)
112
+ self.name == obj.name
113
+ end
114
+
115
+ def [](name)
116
+ alternatives.find{|a| a.name == name}
117
+ end
118
+
119
+ def algorithm
120
+ @algorithm ||= Split.configuration.algorithm
121
+ end
122
+
123
+ def algorithm=(algorithm)
124
+ @algorithm = algorithm.is_a?(String) ? algorithm.constantize : algorithm
125
+ end
126
+
127
+ def resettable=(resettable)
128
+ @resettable = resettable.is_a?(String) ? resettable == 'true' : resettable
129
+ end
130
+
131
+ def alternatives=(alts)
132
+ @alternatives = alts.map do |alternative|
133
+ if alternative.kind_of?(Split::Alternative)
134
+ alternative
135
+ else
136
+ Split::Alternative.new(alternative, @name)
137
+ end
138
+ end
139
+ end
140
+
141
+ def winner
142
+ experiment_winner = redis.hget(:experiment_winner, name)
143
+ if experiment_winner
144
+ Split::Alternative.new(experiment_winner, name)
145
+ else
146
+ nil
147
+ end
148
+ end
149
+
150
+ def has_winner?
151
+ return @has_winner if defined? @has_winner
152
+ @has_winner = !winner.nil?
153
+ end
154
+
155
+ def winner=(winner_name)
156
+ redis.hset(:experiment_winner, name, winner_name.to_s)
157
+ @has_winner = true
158
+ end
159
+
160
+ def participant_count
161
+ alternatives.inject(0){|sum,a| sum + a.participant_count}
162
+ end
163
+
164
+ def control
165
+ alternatives.first
166
+ end
167
+
168
+ def reset_winner
169
+ redis.hdel(:experiment_winner, name)
170
+ @has_winner = false
171
+ end
172
+
173
+ def start
174
+ redis.hset(:experiment_start_times, @name, Time.now.to_i)
175
+ end
176
+
177
+ def start_time
178
+ t = redis.hget(:experiment_start_times, @name)
179
+ if t
180
+ # Check if stored time is an integer
181
+ if t =~ /^[-+]?[0-9]+$/
182
+ Time.at(t.to_i)
183
+ else
184
+ Time.parse(t)
185
+ end
186
+ end
187
+ end
188
+
189
+ def next_alternative
190
+ winner || random_alternative
191
+ end
192
+
193
+ def random_alternative
194
+ if alternatives.length > 1
195
+ algorithm.choose_alternative(self)
196
+ else
197
+ alternatives.first
198
+ end
199
+ end
200
+
201
+ def version
202
+ @version ||= (redis.get("#{name}:version").to_i || 0)
203
+ end
204
+
205
+ def increment_version
206
+ @version = redis.incr("#{name}:version")
207
+ end
208
+
209
+ def key
210
+ if version.to_i > 0
211
+ "#{name}:#{version}"
212
+ else
213
+ name
214
+ end
215
+ end
216
+
217
+ def goals_key
218
+ "#{name}:goals"
219
+ end
220
+
221
+ def finished_key
222
+ self.class.finished_key(key)
223
+ end
224
+
225
+ def metadata_key
226
+ "#{name}:metadata"
227
+ end
228
+
229
+ def resettable?
230
+ resettable
231
+ end
232
+
233
+ def reset
234
+ Split.configuration.on_before_experiment_reset.call(self)
235
+ alternatives.each(&:reset)
236
+ reset_winner
237
+ Split.configuration.on_experiment_reset.call(self)
238
+ increment_version
239
+ end
240
+
241
+ def delete
242
+ Split.configuration.on_before_experiment_delete.call(self)
243
+ if Split.configuration.start_manually
244
+ redis.hdel(:experiment_start_times, @name)
245
+ end
246
+ reset_winner
247
+ redis.srem(:experiments, name)
248
+ remove_experiment_configuration
249
+ Split.configuration.on_experiment_delete.call(self)
250
+ increment_version
251
+ end
252
+
253
+ def delete_metadata
254
+ redis.del(metadata_key)
255
+ end
256
+
257
+ def load_from_redis
258
+ exp_config = redis.hgetall(experiment_config_key)
259
+
260
+ options = {
261
+ resettable: exp_config['resettable'],
262
+ algorithm: exp_config['algorithm'],
263
+ alternatives: load_alternatives_from_redis,
264
+ goals: Split::GoalsCollection.new(@name).load_from_redis,
265
+ metadata: load_metadata_from_redis
266
+ }
267
+
268
+ set_alternatives_and_options(options)
269
+ end
270
+
271
+ def calc_winning_alternatives
272
+ # Cache the winning alternatives so we recalculate them once per the specified interval.
273
+ intervals_since_epoch =
274
+ Time.now.utc.to_i / Split.configuration.winning_alternative_recalculation_interval
275
+
276
+ if self.calc_time != intervals_since_epoch
277
+ if goals.empty?
278
+ self.estimate_winning_alternative
279
+ else
280
+ goals.each do |goal|
281
+ self.estimate_winning_alternative(goal)
282
+ end
283
+ end
284
+
285
+ self.calc_time = intervals_since_epoch
286
+
287
+ self.save
288
+ end
289
+ end
290
+
291
+ def estimate_winning_alternative(goal = nil)
292
+ # initialize a hash of beta distributions based on the alternatives' conversion rates
293
+ beta_params = calc_beta_params(goal)
294
+
295
+ winning_alternatives = []
296
+
297
+ Split.configuration.beta_probability_simulations.times do
298
+ # calculate simulated conversion rates from the beta distributions
299
+ simulated_cr_hash = calc_simulated_conversion_rates(beta_params)
300
+
301
+ winning_alternative = find_simulated_winner(simulated_cr_hash)
302
+
303
+ # push the winning pair to the winning_alternatives array
304
+ winning_alternatives.push(winning_alternative)
305
+ end
306
+
307
+ winning_counts = count_simulated_wins(winning_alternatives)
308
+
309
+ @alternative_probabilities = calc_alternative_probabilities(winning_counts, Split.configuration.beta_probability_simulations)
310
+
311
+ write_to_alternatives(goal)
312
+
313
+ self.save
314
+ end
315
+
316
+ def write_to_alternatives(goal = nil)
317
+ alternatives.each do |alternative|
318
+ alternative.set_p_winner(@alternative_probabilities[alternative], goal)
319
+ end
320
+ end
321
+
322
+ def calc_alternative_probabilities(winning_counts, number_of_simulations)
323
+ alternative_probabilities = {}
324
+ winning_counts.each do |alternative, wins|
325
+ alternative_probabilities[alternative] = wins / number_of_simulations.to_f
326
+ end
327
+ return alternative_probabilities
328
+ end
329
+
330
+ def count_simulated_wins(winning_alternatives)
331
+ # initialize a hash to keep track of winning alternative in simulations
332
+ winning_counts = {}
333
+ alternatives.each do |alternative|
334
+ winning_counts[alternative] = 0
335
+ end
336
+ # count number of times each alternative won, calculate probabilities, place in hash
337
+ winning_alternatives.each do |alternative|
338
+ winning_counts[alternative] += 1
339
+ end
340
+ return winning_counts
341
+ end
342
+
343
+ def find_simulated_winner(simulated_cr_hash)
344
+ # figure out which alternative had the highest simulated conversion rate
345
+ winning_pair = ["",0.0]
346
+ simulated_cr_hash.each do |alternative, rate|
347
+ if rate > winning_pair[1]
348
+ winning_pair = [alternative, rate]
349
+ end
350
+ end
351
+ winner = winning_pair[0]
352
+ return winner
353
+ end
354
+
355
+ def calc_simulated_conversion_rates(beta_params)
356
+ # initialize a random variable (from which to simulate conversion rates ~beta-distributed)
357
+ rand = SimpleRandom.new
358
+ rand.set_seed
359
+
360
+ simulated_cr_hash = {}
361
+
362
+ # create a hash which has the conversion rate pulled from each alternative's beta distribution
363
+ beta_params.each do |alternative, params|
364
+ alpha = params[0]
365
+ beta = params[1]
366
+ simulated_conversion_rate = rand.beta(alpha, beta)
367
+ simulated_cr_hash[alternative] = simulated_conversion_rate
368
+ end
369
+
370
+ return simulated_cr_hash
371
+ end
372
+
373
+ def calc_beta_params(goal = nil)
374
+ beta_params = {}
375
+ alternatives.each do |alternative|
376
+ conversions = goal.nil? ? alternative.completed_count : alternative.completed_count(goal)
377
+ alpha = 1 + conversions
378
+ beta = 1 + alternative.participant_count - conversions
379
+
380
+ params = [alpha, beta]
381
+
382
+ beta_params[alternative] = params
383
+ end
384
+ return beta_params
385
+ end
386
+
387
+ def calc_time=(time)
388
+ redis.hset(experiment_config_key, :calc_time, time)
389
+ end
390
+
391
+ def calc_time
392
+ redis.hget(experiment_config_key, :calc_time).to_i
393
+ end
394
+
395
+ def jstring(goal = nil)
396
+ js_id = if goal.nil?
397
+ name
398
+ else
399
+ name + "-" + goal
400
+ end
401
+ js_id.gsub('/', '--')
402
+ end
403
+
404
+ protected
405
+
406
+ def experiment_config_key
407
+ "experiment_configurations/#{@name}"
408
+ end
409
+
410
+ def load_metadata_from_configuration
411
+ Split.configuration.experiment_for(@name)[:metadata]
412
+ end
413
+
414
+ def load_metadata_from_redis
415
+ meta = redis.get(metadata_key)
416
+ JSON.parse(meta) unless meta.nil?
417
+ end
418
+
419
+ def load_alternatives_from_configuration
420
+ alts = Split.configuration.experiment_for(@name)[:alternatives]
421
+ raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
422
+ if alts.is_a?(Hash)
423
+ alts.keys
424
+ else
425
+ alts.flatten
426
+ end
427
+ end
428
+
429
+ def load_alternatives_from_redis
430
+ alternatives = case redis.type(@name)
431
+ when 'set' # convert legacy sets to lists
432
+ alts = redis.smembers(@name)
433
+ redis.del(@name)
434
+ alts.reverse.each {|a| redis.lpush(@name, a) }
435
+ redis.lrange(@name, 0, -1)
436
+ else
437
+ redis.lrange(@name, 0, -1)
438
+ end
439
+ alternatives.map do |alt|
440
+ alt = begin
441
+ JSON.parse(alt)
442
+ rescue
443
+ alt
444
+ end
445
+ Split::Alternative.new(alt, @name)
446
+ end
447
+ end
448
+
449
+ private
450
+
451
+ def redis
452
+ Split.redis
453
+ end
454
+
455
+ def redis_interface
456
+ RedisInterface.new
457
+ end
458
+
459
+ def persist_experiment_configuration
460
+ redis_interface.add_to_set(:experiments, name)
461
+ redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
462
+ goals_collection.save
463
+ redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
464
+ end
465
+
466
+ def remove_experiment_configuration
467
+ @alternatives.each(&:delete)
468
+ goals_collection.delete
469
+ delete_metadata
470
+ redis.del(@name)
471
+ end
472
+
473
+ def experiment_configuration_has_changed?
474
+ existing_alternatives = load_alternatives_from_redis
475
+ existing_goals = Split::GoalsCollection.new(@name).load_from_redis
476
+ existing_metadata = load_metadata_from_redis
477
+ existing_alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
478
+ existing_goals != @goals ||
479
+ existing_metadata != @metadata
480
+ end
481
+
482
+ def goals_collection
483
+ Split::GoalsCollection.new(@name, @goals)
484
+ end
485
+ end
486
+ end