split 0.5.0 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.travis.yml +1 -3
- data/CHANGELOG.mdown +25 -6
- data/Gemfile +1 -1
- data/README.mdown +137 -52
- data/lib/split.rb +1 -1
- data/lib/split/alternative.rb +54 -26
- data/lib/split/configuration.rb +94 -30
- data/lib/split/dashboard/public/style.css +10 -2
- data/lib/split/dashboard/views/_experiment.erb +31 -22
- data/lib/split/dashboard/views/_experiment_with_goal_header.erb +14 -0
- data/lib/split/dashboard/views/index.erb +8 -1
- data/lib/split/experiment.rb +166 -146
- data/lib/split/helper.rb +33 -23
- data/lib/split/metric.rb +13 -0
- data/lib/split/persistence/cookie_adapter.rb +10 -2
- data/lib/split/trial.rb +18 -12
- data/lib/split/version.rb +3 -3
- data/spec/alternative_spec.rb +121 -78
- data/spec/configuration_spec.rb +92 -4
- data/spec/dashboard_spec.rb +27 -11
- data/spec/experiment_spec.rb +110 -67
- data/spec/helper_spec.rb +287 -104
- data/spec/persistence/cookie_adapter_spec.rb +8 -1
- data/spec/spec_helper.rb +1 -0
- data/spec/trial_spec.rb +10 -8
- data/split.gemspec +1 -1
- metadata +19 -18
data/lib/split/configuration.rb
CHANGED
@@ -1,20 +1,9 @@
|
|
1
1
|
module Split
|
2
2
|
class Configuration
|
3
|
-
|
4
|
-
'Baidu' => 'Chinese spider',
|
5
|
-
'Gigabot' => 'Gigabot spider',
|
6
|
-
'Googlebot' => 'Google spider',
|
7
|
-
'libwww-perl' => 'Perl client-server library loved by script kids',
|
8
|
-
'lwp-trivial' => 'Another Perl library loved by script kids',
|
9
|
-
'msnbot' => 'Microsoft bot',
|
10
|
-
'SiteUptime' => 'Site monitoring services',
|
11
|
-
'Slurp' => 'Yahoo spider',
|
12
|
-
'WordPress' => 'WordPress spider',
|
13
|
-
'ZIBB' => 'ZIBB spider',
|
14
|
-
'ZyBorg' => 'Zyborg? Hmmm....'
|
15
|
-
}
|
3
|
+
attr_accessor :bots
|
16
4
|
attr_accessor :robot_regex
|
17
5
|
attr_accessor :ignore_ip_addresses
|
6
|
+
attr_accessor :ignore_filter
|
18
7
|
attr_accessor :db_failover
|
19
8
|
attr_accessor :db_failover_on_db_error
|
20
9
|
attr_accessor :db_failover_allow_parameter_override
|
@@ -24,13 +13,64 @@ module Split
|
|
24
13
|
attr_accessor :persistence
|
25
14
|
attr_accessor :algorithm
|
26
15
|
|
16
|
+
def bots
|
17
|
+
@bots ||= {
|
18
|
+
# Indexers
|
19
|
+
"AdsBot-Google" => 'Google Adwords',
|
20
|
+
'Baidu' => 'Chinese search engine',
|
21
|
+
'Gigabot' => 'Gigabot spider',
|
22
|
+
'Googlebot' => 'Google spider',
|
23
|
+
'msnbot' => 'Microsoft bot',
|
24
|
+
'bingbot' => 'Microsoft bing bot',
|
25
|
+
'rogerbot' => 'SeoMoz spider',
|
26
|
+
'Slurp' => 'Yahoo spider',
|
27
|
+
'Sogou' => 'Chinese search engine',
|
28
|
+
"spider" => 'generic web spider',
|
29
|
+
'WordPress' => 'WordPress spider',
|
30
|
+
'ZIBB' => 'ZIBB spider',
|
31
|
+
'YandexBot' => 'Yandex spider',
|
32
|
+
# HTTP libraries
|
33
|
+
'Apache-HttpClient' => 'Java http library',
|
34
|
+
'AppEngine-Google' => 'Google App Engine',
|
35
|
+
"curl" => 'curl unix CLI http client',
|
36
|
+
'ColdFusion' => 'ColdFusion http library',
|
37
|
+
"EventMachine HttpClient" => 'Ruby http library',
|
38
|
+
"Go http package" => 'Go http library',
|
39
|
+
'Java' => 'Generic Java http library',
|
40
|
+
'libwww-perl' => 'Perl client-server library loved by script kids',
|
41
|
+
'lwp-trivial' => 'Another Perl library loved by script kids',
|
42
|
+
"Python-urllib" => 'Python http library',
|
43
|
+
"PycURL" => 'Python http library',
|
44
|
+
"Test Certificate Info" => 'C http library?',
|
45
|
+
"Wget" => 'wget unix CLI http client',
|
46
|
+
# URL expanders / previewers
|
47
|
+
'awe.sm' => 'Awe.sm URL expander',
|
48
|
+
"bitlybot" => 'bit.ly bot',
|
49
|
+
"facebookexternalhit" => 'facebook bot',
|
50
|
+
'LongURL' => 'URL expander service',
|
51
|
+
'Twitterbot' => 'Twitter URL expander',
|
52
|
+
'UnwindFetch' => 'Gnip URL expander',
|
53
|
+
# Uptime monitoring
|
54
|
+
'check_http' => 'Nagios monitor',
|
55
|
+
'NewRelicPinger' => 'NewRelic monitor',
|
56
|
+
'Panopta' => 'Monitoring service',
|
57
|
+
"Pingdom" => 'Pingdom monitoring',
|
58
|
+
'SiteUptime' => 'Site monitoring services',
|
59
|
+
# ???
|
60
|
+
"DigitalPersona Fingerprint Software" => 'HP Fingerprint scanner',
|
61
|
+
"ShowyouBot" => 'Showyou iOS app spider',
|
62
|
+
'ZyBorg' => 'Zyborg? Hmmm....',
|
63
|
+
}
|
64
|
+
end
|
65
|
+
|
27
66
|
def disabled?
|
28
67
|
!enabled
|
29
68
|
end
|
30
69
|
|
31
70
|
def experiment_for(name)
|
32
71
|
if normalized_experiments
|
33
|
-
|
72
|
+
# TODO symbols
|
73
|
+
normalized_experiments[name.to_sym]
|
34
74
|
end
|
35
75
|
end
|
36
76
|
|
@@ -39,37 +79,44 @@ module Split
|
|
39
79
|
@metrics = {}
|
40
80
|
if self.experiments
|
41
81
|
self.experiments.each do |key, value|
|
42
|
-
metric_name = value
|
82
|
+
metric_name = value_for(value, :metric).to_sym rescue nil
|
43
83
|
if metric_name
|
44
84
|
@metrics[metric_name] ||= []
|
45
|
-
@metrics[metric_name] << Split::Experiment.
|
85
|
+
@metrics[metric_name] << Split::Experiment.new(key)
|
46
86
|
end
|
47
87
|
end
|
48
88
|
end
|
49
89
|
@metrics
|
50
90
|
end
|
51
|
-
|
91
|
+
|
52
92
|
def normalized_experiments
|
53
93
|
if @experiments.nil?
|
54
94
|
nil
|
55
95
|
else
|
56
96
|
experiment_config = {}
|
57
|
-
@experiments.keys.each do |
|
58
|
-
experiment_config[name] = {}
|
97
|
+
@experiments.keys.each do |name|
|
98
|
+
experiment_config[name.to_sym] = {}
|
59
99
|
end
|
60
|
-
|
61
|
-
|
100
|
+
|
101
|
+
@experiments.each do |experiment_name, settings|
|
102
|
+
if alternatives = value_for(settings, :alternatives)
|
103
|
+
experiment_config[experiment_name.to_sym][:alternatives] = normalize_alternatives(alternatives)
|
104
|
+
end
|
105
|
+
|
106
|
+
if goals = value_for(settings, :goals)
|
107
|
+
experiment_config[experiment_name.to_sym][:goals] = goals
|
108
|
+
end
|
62
109
|
end
|
110
|
+
|
63
111
|
experiment_config
|
64
112
|
end
|
65
113
|
end
|
66
|
-
|
67
|
-
|
114
|
+
|
68
115
|
def normalize_alternatives(alternatives)
|
69
116
|
given_probability, num_with_probability = alternatives.inject([0,0]) do |a,v|
|
70
117
|
p, n = a
|
71
|
-
if
|
72
|
-
[p +
|
118
|
+
if percent = value_for(v, :percent)
|
119
|
+
[p + percent, n + 1]
|
73
120
|
else
|
74
121
|
a
|
75
122
|
end
|
@@ -80,14 +127,15 @@ module Split
|
|
80
127
|
|
81
128
|
if num_with_probability.nonzero?
|
82
129
|
alternatives = alternatives.map do |v|
|
83
|
-
if
|
84
|
-
{
|
85
|
-
elsif
|
86
|
-
{
|
130
|
+
if (name = value_for(v, :name)) && (percent = value_for(v, :percent))
|
131
|
+
{ name => percent / 100.0 }
|
132
|
+
elsif name = value_for(v, :name)
|
133
|
+
{ name => unassigned_probability }
|
87
134
|
else
|
88
135
|
{ v => unassigned_probability }
|
89
136
|
end
|
90
137
|
end
|
138
|
+
|
91
139
|
[alternatives.shift, alternatives]
|
92
140
|
else
|
93
141
|
alternatives = alternatives.dup
|
@@ -95,9 +143,13 @@ module Split
|
|
95
143
|
end
|
96
144
|
end
|
97
145
|
|
146
|
+
def robot_regex
|
147
|
+
@robot_regex ||= /\b(?:#{escaped_bots.join('|')})\b|\A\W*\z/i
|
148
|
+
end
|
149
|
+
|
98
150
|
def initialize
|
99
|
-
@robot_regex = /\b(#{BOTS.keys.join('|')})\b/i
|
100
151
|
@ignore_ip_addresses = []
|
152
|
+
@ignore_filter = proc{ |request| is_robot? || is_ignored_ip_address? }
|
101
153
|
@db_failover = false
|
102
154
|
@db_failover_on_db_error = proc{|error|} # e.g. use Rails logger here
|
103
155
|
@db_failover_allow_parameter_override = false
|
@@ -107,5 +159,17 @@ module Split
|
|
107
159
|
@persistence = Split::Persistence::SessionAdapter
|
108
160
|
@algorithm = Split::Algorithms::WeightedSample
|
109
161
|
end
|
162
|
+
|
163
|
+
private
|
164
|
+
|
165
|
+
def value_for(hash, key)
|
166
|
+
if hash.kind_of?(Hash)
|
167
|
+
hash[key.to_s] || hash[key.to_sym]
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def escaped_bots
|
172
|
+
bots.map { |key, _| Regexp.escape(key) }
|
173
|
+
end
|
110
174
|
end
|
111
175
|
end
|
@@ -160,6 +160,10 @@ body {
|
|
160
160
|
margin:30px 0;
|
161
161
|
}
|
162
162
|
|
163
|
+
.experiment_with_goal {
|
164
|
+
margin: -32px 0 30px 0;
|
165
|
+
}
|
166
|
+
|
163
167
|
.experiment .experiment-header {
|
164
168
|
background: #f4f4f4;
|
165
169
|
background: -webkit-gradient(linear, left top, left bottom,
|
@@ -177,14 +181,18 @@ body {
|
|
177
181
|
|
178
182
|
.experiment h2 {
|
179
183
|
color:#888;
|
180
|
-
margin: 12px 0 0;
|
184
|
+
margin: 12px 0 12px 0;
|
181
185
|
font-size: 1em;
|
182
186
|
font-weight:bold;
|
183
187
|
float:left;
|
184
188
|
text-shadow:0 1px 0 rgba(255,255,255,0.8);
|
185
189
|
}
|
186
190
|
|
187
|
-
.experiment h2 .
|
191
|
+
.experiment h2 .goal {
|
192
|
+
font-style: italic;
|
193
|
+
}
|
194
|
+
|
195
|
+
.experiment h2 .version {
|
188
196
|
font-style:italic;
|
189
197
|
font-size:0.8em;
|
190
198
|
color:#bbb;
|
@@ -1,20 +1,28 @@
|
|
1
|
-
|
1
|
+
<% unless goal.nil? %>
|
2
|
+
<% experiment_class = "experiment experiment_with_goal" %>
|
3
|
+
<% else %>
|
4
|
+
<% experiment_class = "experiment" %>
|
5
|
+
<% end %>
|
6
|
+
<div class="<%= experiment_class %>">
|
2
7
|
<div class="experiment-header">
|
3
8
|
<h2>
|
4
9
|
Experiment: <%= experiment.name %>
|
5
10
|
<% if experiment.version > 1 %><span class='version'>v<%= experiment.version %></span><% end %>
|
11
|
+
<% unless goal.nil? %><span class='goal'>Goal:<%= goal %></span><% end %>
|
6
12
|
</h2>
|
7
13
|
|
8
|
-
|
9
|
-
<
|
10
|
-
|
11
|
-
<
|
12
|
-
|
13
|
-
|
14
|
-
<
|
15
|
-
|
16
|
-
|
17
|
-
|
14
|
+
<% if goal.nil? %>
|
15
|
+
<div class='inline-controls'>
|
16
|
+
<small><%= experiment.start_time ? experiment.start_time.strftime('%Y-%m-%d') : 'Unknown' %></small>
|
17
|
+
<form action="<%= url "/reset/#{experiment.name}" %>" method='post' onclick="return confirmReset()">
|
18
|
+
<input type="submit" value="Reset Data">
|
19
|
+
</form>
|
20
|
+
<form action="<%= url "/#{experiment.name}" %>" method='post' onclick="return confirmDelete()">
|
21
|
+
<input type="hidden" name="_method" value="delete"/>
|
22
|
+
<input type="submit" value="Delete" class="red">
|
23
|
+
</form>
|
24
|
+
</div>
|
25
|
+
<% end %>
|
18
26
|
</div>
|
19
27
|
<table>
|
20
28
|
<tr>
|
@@ -27,7 +35,7 @@
|
|
27
35
|
<th>Finish</th>
|
28
36
|
</tr>
|
29
37
|
|
30
|
-
<% total_participants = total_completed = 0 %>
|
38
|
+
<% total_participants = total_completed = total_unfinished = 0 %>
|
31
39
|
<% experiment.alternatives.each do |alternative| %>
|
32
40
|
<tr>
|
33
41
|
<td>
|
@@ -38,23 +46,23 @@
|
|
38
46
|
</td>
|
39
47
|
<td><%= alternative.participant_count %></td>
|
40
48
|
<td><%= alternative.unfinished_count %></td>
|
41
|
-
<td><%= alternative.completed_count %></td>
|
49
|
+
<td><%= alternative.completed_count(goal) %></td>
|
42
50
|
<td>
|
43
|
-
<%= number_to_percentage(alternative.conversion_rate) %>%
|
44
|
-
<% if experiment.control.conversion_rate > 0 && !alternative.control? %>
|
45
|
-
<% if alternative.conversion_rate > experiment.control.conversion_rate %>
|
51
|
+
<%= number_to_percentage(alternative.conversion_rate(goal)) %>%
|
52
|
+
<% if experiment.control.conversion_rate(goal) > 0 && !alternative.control? %>
|
53
|
+
<% if alternative.conversion_rate(goal) > experiment.control.conversion_rate(goal) %>
|
46
54
|
<span class='better'>
|
47
|
-
+<%= number_to_percentage((alternative.conversion_rate/experiment.control.conversion_rate)-1) %>%
|
55
|
+
+<%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>%
|
48
56
|
</span>
|
49
|
-
<% elsif alternative.conversion_rate < experiment.control.conversion_rate %>
|
57
|
+
<% elsif alternative.conversion_rate(goal) < experiment.control.conversion_rate(goal) %>
|
50
58
|
<span class='worse'>
|
51
|
-
<%= number_to_percentage((alternative.conversion_rate/experiment.control.conversion_rate)-1) %>%
|
59
|
+
<%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>%
|
52
60
|
</span>
|
53
61
|
<% end %>
|
54
62
|
<% end %>
|
55
63
|
</td>
|
56
64
|
<td>
|
57
|
-
<span title='z-score: <%= round(alternative.z_score, 3) %>'><%= confidence_level(alternative.z_score) %></span>
|
65
|
+
<span title='z-score: <%= round(alternative.z_score(goal), 3) %>'><%= confidence_level(alternative.z_score(goal)) %></span>
|
58
66
|
</td>
|
59
67
|
<td>
|
60
68
|
<% if experiment.winner %>
|
@@ -73,13 +81,14 @@
|
|
73
81
|
</tr>
|
74
82
|
|
75
83
|
<% total_participants += alternative.participant_count %>
|
76
|
-
<%
|
84
|
+
<% total_unfinished += alternative.unfinished_count %>
|
85
|
+
<% total_completed += alternative.completed_count(goal) %>
|
77
86
|
<% end %>
|
78
87
|
|
79
88
|
<tr class="totals">
|
80
89
|
<td>Totals</td>
|
81
90
|
<td><%= total_participants %></td>
|
82
|
-
<td><%=
|
91
|
+
<td><%= total_unfinished %></td>
|
83
92
|
<td><%= total_completed %></td>
|
84
93
|
<td>N/A</td>
|
85
94
|
<td>N/A</td>
|
@@ -0,0 +1,14 @@
|
|
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
|
+
<form action="<%= url "/reset/#{experiment.name}" %>" method='post' onclick="return confirmReset()">
|
6
|
+
<input type="submit" value="Reset Data">
|
7
|
+
</form>
|
8
|
+
<form action="<%= url "/#{experiment.name}" %>" method='post' onclick="return confirmDelete()">
|
9
|
+
<input type="hidden" name="_method" value="delete"/>
|
10
|
+
<input type="submit" value="Delete" class="red">
|
11
|
+
</form>
|
12
|
+
</div>
|
13
|
+
</div>
|
14
|
+
</div>
|
@@ -2,7 +2,14 @@
|
|
2
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
3
|
|
4
4
|
<% @experiments.each do |experiment| %>
|
5
|
-
|
5
|
+
<% if experiment.goals.empty? %>
|
6
|
+
<%= erb :_experiment, :locals => {:goal => nil, :experiment => experiment} %>
|
7
|
+
<% else %>
|
8
|
+
<%= erb :_experiment_with_goal_header, :locals => {:experiment => experiment} %>
|
9
|
+
<% experiment.goals.each do |g| %>
|
10
|
+
<%= erb :_experiment, :locals => {:goal => g, :experiment => experiment} %>
|
11
|
+
<% end %>
|
12
|
+
<% end %>
|
6
13
|
<% end %>
|
7
14
|
<% else %>
|
8
15
|
<p class="intro">No experiments have started yet, you need to define them in your code and introduce them to your users.</p>
|
data/lib/split/experiment.rb
CHANGED
@@ -3,40 +3,134 @@ module Split
|
|
3
3
|
attr_accessor :name
|
4
4
|
attr_writer :algorithm
|
5
5
|
attr_accessor :resettable
|
6
|
+
attr_accessor :goals
|
7
|
+
attr_accessor :alternatives
|
6
8
|
|
7
|
-
def initialize(name, options = {})
|
9
|
+
def initialize(name, options = {})
|
8
10
|
options = {
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
@name = name.to_s
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
11
|
+
:resettable => true,
|
12
|
+
}.merge(options)
|
13
|
+
|
14
|
+
@name = name.to_s
|
15
|
+
|
16
|
+
alts = options[:alternatives] || []
|
17
|
+
|
18
|
+
if alts.length == 1
|
19
|
+
if alts[0].is_a? Hash
|
20
|
+
alts = alts[0].map{|k,v| {k => v} }
|
21
|
+
end
|
17
22
|
end
|
18
|
-
|
19
|
-
if
|
20
|
-
|
23
|
+
|
24
|
+
if alts.empty?
|
25
|
+
exp_config = Split.configuration.experiment_for(name)
|
26
|
+
if exp_config
|
27
|
+
alts = load_alternatives_from_configuration
|
28
|
+
options[:goals] = load_goals_from_configuration
|
29
|
+
options[:resettable] = exp_config[:resettable]
|
30
|
+
options[:algorithm] = exp_config[:algorithm]
|
31
|
+
end
|
21
32
|
end
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
33
|
+
|
34
|
+
self.alternatives = alts
|
35
|
+
self.goals = options[:goals]
|
36
|
+
self.algorithm = options[:algorithm]
|
37
|
+
self.resettable = options[:resettable]
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.all
|
41
|
+
Split.redis.smembers(:experiments).map {|e| find(e)}
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.find(name)
|
45
|
+
if Split.redis.exists(name)
|
46
|
+
obj = self.new name
|
47
|
+
obj.load_from_redis
|
48
|
+
else
|
49
|
+
obj = nil
|
27
50
|
end
|
28
|
-
|
29
|
-
|
51
|
+
obj
|
30
52
|
end
|
31
|
-
|
32
|
-
def
|
33
|
-
|
53
|
+
|
54
|
+
def self.find_or_create(label, *alternatives)
|
55
|
+
experiment_name_with_version, goals = normalize_experiment(label)
|
56
|
+
name = experiment_name_with_version.to_s.split(':')[0]
|
57
|
+
|
58
|
+
exp = self.new name, :alternatives => alternatives, :goals => goals
|
59
|
+
exp.save
|
60
|
+
exp
|
61
|
+
end
|
62
|
+
|
63
|
+
def save
|
64
|
+
validate!
|
65
|
+
|
66
|
+
if new_record?
|
67
|
+
Split.redis.sadd(:experiments, name)
|
68
|
+
Split.redis.hset(:experiment_start_times, @name, Time.now)
|
69
|
+
@alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
|
70
|
+
@goals.reverse.each {|a| Split.redis.lpush(goals_key, a)} unless @goals.nil?
|
71
|
+
else
|
72
|
+
|
73
|
+
existing_alternatives = load_alternatives_from_redis
|
74
|
+
existing_goals = load_goals_from_redis
|
75
|
+
unless existing_alternatives == @alternatives.map(&:name) && existing_goals == @goals
|
76
|
+
reset
|
77
|
+
@alternatives.each(&:delete)
|
78
|
+
delete_goals
|
79
|
+
Split.redis.del(@name)
|
80
|
+
@alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
|
81
|
+
@goals.reverse.each {|a| Split.redis.lpush(goals_key, a)} unless @goals.nil?
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
Split.redis.hset(experiment_config_key, :resettable, resettable)
|
86
|
+
Split.redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
|
87
|
+
self
|
88
|
+
end
|
89
|
+
|
90
|
+
def validate!
|
91
|
+
if @alternatives.empty? && Split.configuration.experiment_for(@name).nil?
|
92
|
+
raise ExperimentNotFound.new("Experiment #{@name} not found")
|
93
|
+
end
|
94
|
+
@alternatives.each {|a| a.validate! }
|
95
|
+
unless @goals.nil? || goals.kind_of?(Array)
|
96
|
+
raise ArgumentError, 'Goals must be an array'
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def new_record?
|
101
|
+
!Split.redis.exists(name)
|
34
102
|
end
|
35
103
|
|
36
104
|
def ==(obj)
|
37
105
|
self.name == obj.name
|
38
106
|
end
|
39
107
|
|
108
|
+
def [](name)
|
109
|
+
alternatives.find{|a| a.name == name}
|
110
|
+
end
|
111
|
+
|
112
|
+
def algorithm
|
113
|
+
@algorithm ||= Split.configuration.algorithm
|
114
|
+
end
|
115
|
+
|
116
|
+
def algorithm=(algorithm)
|
117
|
+
@algorithm = algorithm.is_a?(String) ? algorithm.constantize : algorithm
|
118
|
+
end
|
119
|
+
|
120
|
+
def resettable=(resettable)
|
121
|
+
@resettable = resettable.is_a?(String) ? resettable == 'true' : resettable
|
122
|
+
end
|
123
|
+
|
124
|
+
def alternatives=(alts)
|
125
|
+
@alternatives = alts.map do |alternative|
|
126
|
+
if alternative.kind_of?(Split::Alternative)
|
127
|
+
alternative
|
128
|
+
else
|
129
|
+
Split::Alternative.new(alternative, @name)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
40
134
|
def winner
|
41
135
|
if w = Split.redis.hget(:experiment_winner, name)
|
42
136
|
Split::Alternative.new(w, name)
|
@@ -44,7 +138,11 @@ module Split
|
|
44
138
|
nil
|
45
139
|
end
|
46
140
|
end
|
47
|
-
|
141
|
+
|
142
|
+
def winner=(winner_name)
|
143
|
+
Split.redis.hset(:experiment_winner, name, winner_name.to_s)
|
144
|
+
end
|
145
|
+
|
48
146
|
def participant_count
|
49
147
|
alternatives.inject(0){|sum,a| sum + a.participant_count}
|
50
148
|
end
|
@@ -57,26 +155,10 @@ module Split
|
|
57
155
|
Split.redis.hdel(:experiment_winner, name)
|
58
156
|
end
|
59
157
|
|
60
|
-
def winner=(winner_name)
|
61
|
-
Split.redis.hset(:experiment_winner, name, winner_name.to_s)
|
62
|
-
end
|
63
|
-
|
64
158
|
def start_time
|
65
159
|
t = Split.redis.hget(:experiment_start_times, @name)
|
66
160
|
Time.parse(t) if t
|
67
161
|
end
|
68
|
-
|
69
|
-
def [](name)
|
70
|
-
alternatives.find{|a| a.name == name}
|
71
|
-
end
|
72
|
-
|
73
|
-
def alternatives
|
74
|
-
@alternatives.dup
|
75
|
-
end
|
76
|
-
|
77
|
-
def alternative_names
|
78
|
-
@alternatives.map(&:name)
|
79
|
-
end
|
80
162
|
|
81
163
|
def next_alternative
|
82
164
|
winner || random_alternative
|
@@ -106,6 +188,10 @@ module Split
|
|
106
188
|
end
|
107
189
|
end
|
108
190
|
|
191
|
+
def goals_key
|
192
|
+
"#{name}:goals"
|
193
|
+
end
|
194
|
+
|
109
195
|
def finished_key
|
110
196
|
"#{key}:finished"
|
111
197
|
end
|
@@ -125,139 +211,73 @@ module Split
|
|
125
211
|
reset_winner
|
126
212
|
Split.redis.srem(:experiments, name)
|
127
213
|
Split.redis.del(name)
|
214
|
+
delete_goals
|
128
215
|
increment_version
|
129
216
|
end
|
130
217
|
|
131
|
-
def
|
132
|
-
|
218
|
+
def delete_goals
|
219
|
+
Split.redis.del(goals_key)
|
133
220
|
end
|
134
221
|
|
135
|
-
def
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
Split.redis.del(name)
|
142
|
-
@alternatives.reverse.each {|a| Split.redis.lpush(name, a.name) }
|
143
|
-
end
|
144
|
-
config_key = Split::Experiment.experiment_config_key(name)
|
145
|
-
Split.redis.hset(config_key, :resettable, resettable)
|
146
|
-
Split.redis.hset(config_key, :algorithm, algorithm.to_s)
|
147
|
-
self
|
222
|
+
def load_from_redis
|
223
|
+
exp_config = Split.redis.hgetall(experiment_config_key)
|
224
|
+
self.resettable = exp_config['resettable']
|
225
|
+
self.algorithm = exp_config['algorithm']
|
226
|
+
self.alternatives = load_alternatives_from_redis
|
227
|
+
self.goals = load_goals_from_redis
|
148
228
|
end
|
149
229
|
|
150
|
-
|
151
|
-
|
152
|
-
|
230
|
+
protected
|
231
|
+
|
232
|
+
def self.normalize_experiment(label)
|
233
|
+
if Hash === label
|
234
|
+
experiment_name = label.keys.first
|
235
|
+
goals = label.values.first
|
153
236
|
else
|
154
|
-
|
237
|
+
experiment_name = label
|
238
|
+
goals = []
|
155
239
|
end
|
240
|
+
return experiment_name, goals
|
156
241
|
end
|
157
242
|
|
158
|
-
def
|
159
|
-
|
160
|
-
raise ArgumentError, "Experiment configuration is missing :alternatives array" if alts.nil?
|
161
|
-
if alts.is_a?(Hash)
|
162
|
-
alts.keys
|
163
|
-
else
|
164
|
-
alts.flatten
|
165
|
-
end
|
243
|
+
def experiment_config_key
|
244
|
+
"experiment_configurations/#{@name}"
|
166
245
|
end
|
167
246
|
|
168
|
-
def
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
Split.redis.del(name)
|
173
|
-
alts.reverse.each {|a| Split.redis.lpush(name, a) }
|
174
|
-
Split.redis.lrange(name, 0, -1)
|
247
|
+
def load_goals_from_configuration
|
248
|
+
goals = Split.configuration.experiment_for(@name)[:goals]
|
249
|
+
if goals.nil?
|
250
|
+
goals = []
|
175
251
|
else
|
176
|
-
|
252
|
+
goals.flatten
|
177
253
|
end
|
178
254
|
end
|
179
255
|
|
180
|
-
def
|
181
|
-
|
182
|
-
self.new(name, :alternative_names => load_alternatives_for(name),
|
183
|
-
:resettable => exp_config[:resettable],
|
184
|
-
:algorithm => exp_config[:algorithm])
|
256
|
+
def load_goals_from_redis
|
257
|
+
Split.redis.lrange(goals_key, 0, -1)
|
185
258
|
end
|
186
259
|
|
187
|
-
def
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
end
|
193
|
-
|
194
|
-
def self.experiment_config_key(name)
|
195
|
-
"experiment_configurations/#{name}"
|
196
|
-
end
|
197
|
-
|
198
|
-
def self.all
|
199
|
-
Array(all_experiment_names_from_redis + all_experiment_names_from_configuration).map {|e| find(e)}
|
200
|
-
end
|
201
|
-
|
202
|
-
def self.all_experiment_names_from_redis
|
203
|
-
Split.redis.smembers(:experiments)
|
204
|
-
end
|
205
|
-
|
206
|
-
def self.all_experiment_names_from_configuration
|
207
|
-
Split.configuration.experiments ? Split.configuration.experiments.keys : []
|
208
|
-
end
|
209
|
-
|
210
|
-
|
211
|
-
def self.find(name)
|
212
|
-
if Split.configuration.experiment_for(name)
|
213
|
-
obj = load_from_configuration(name)
|
214
|
-
elsif Split.redis.exists(name)
|
215
|
-
obj = load_from_redis(name)
|
260
|
+
def load_alternatives_from_configuration
|
261
|
+
alts = Split.configuration.experiment_for(@name)[:alternatives]
|
262
|
+
raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
|
263
|
+
if alts.is_a?(Hash)
|
264
|
+
alts.keys
|
216
265
|
else
|
217
|
-
|
266
|
+
alts.flatten
|
218
267
|
end
|
219
|
-
obj
|
220
268
|
end
|
221
269
|
|
222
|
-
def
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
end
|
230
|
-
|
231
|
-
alts = initialize_alternatives(alternatives, name)
|
232
|
-
|
233
|
-
if Split.redis.exists(name)
|
234
|
-
existing_alternatives = load_alternatives_for(name)
|
235
|
-
if existing_alternatives == alts.map(&:name)
|
236
|
-
experiment = self.new(name, :alternative_names => alternatives)
|
237
|
-
else
|
238
|
-
exp = self.new(name, :alternative_names => existing_alternatives)
|
239
|
-
exp.reset
|
240
|
-
exp.alternatives.each(&:delete)
|
241
|
-
experiment = self.new(name, :alternative_names =>alternatives)
|
242
|
-
experiment.save
|
243
|
-
end
|
270
|
+
def load_alternatives_from_redis
|
271
|
+
case Split.redis.type(@name)
|
272
|
+
when 'set' # convert legacy sets to lists
|
273
|
+
alts = Split.redis.smembers(@name)
|
274
|
+
Split.redis.del(@name)
|
275
|
+
alts.reverse.each {|a| Split.redis.lpush(@name, a) }
|
276
|
+
Split.redis.lrange(@name, 0, -1)
|
244
277
|
else
|
245
|
-
|
246
|
-
experiment.save
|
278
|
+
Split.redis.lrange(@name, 0, -1)
|
247
279
|
end
|
248
|
-
return experiment
|
249
|
-
|
250
280
|
end
|
251
281
|
|
252
|
-
def self.initialize_alternatives(alternatives, name)
|
253
|
-
|
254
|
-
unless alternatives.all? { |a| Split::Alternative.valid?(a) }
|
255
|
-
raise ArgumentError, 'Alternatives must be strings'
|
256
|
-
end
|
257
|
-
|
258
|
-
alternatives.map do |alternative|
|
259
|
-
Split::Alternative.new(alternative, name)
|
260
|
-
end
|
261
|
-
end
|
262
282
|
end
|
263
283
|
end
|