split 0.5.0 → 0.6.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.
- 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
|