ab-split 1.0.0

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.
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