laboratory 0.1.1 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/linters.yml +24 -0
- data/.rubocop.yml +11 -0
- data/.rubocop_defaults.yml +3963 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +1 -1
- data/README.md +40 -2
- data/Rakefile +3 -3
- data/bin/console +4 -4
- data/laboratory.gemspec +8 -5
- data/lib/laboratory/adapters/redis_adapter.rb +34 -15
- data/lib/laboratory/algorithms/random.rb +1 -1
- data/lib/laboratory/calculations/confidence_level.rb +7 -4
- data/lib/laboratory/calculations/z_score.rb +17 -15
- data/lib/laboratory/experiment/analysis_summary.rb +7 -5
- data/lib/laboratory/experiment/variant.rb +5 -1
- data/lib/laboratory/experiment.rb +57 -21
- data/lib/laboratory/ui/helpers.rb +12 -7
- data/lib/laboratory/ui.rb +4 -4
- data/lib/laboratory/version.rb +1 -1
- metadata +15 -12
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -13,6 +13,18 @@ Laboratory is an A/B testing and Feature Flag framework for Rails. It's focused
|
|
13
13
|
|
14
14
|
Laboratory builds upon great work from other gems, in particular [Split](https://github.com/splitrb/split).
|
15
15
|
|
16
|
+
Laboratory is in active development, see the bottom for a todo list.
|
17
|
+
|
18
|
+
## Preview of UI Interface
|
19
|
+
|
20
|
+
### Viewing and Analysing experiments
|
21
|
+
|
22
|
+
![ui-interface](https://imgur.com/l23iiet.png)
|
23
|
+
|
24
|
+
### Editting an experiment
|
25
|
+
|
26
|
+
![edit-ui-interface](https://imgur.com/O6JkDk0.png)
|
27
|
+
|
16
28
|
## Installation
|
17
29
|
|
18
30
|
Add this line to your application's Gemfile:
|
@@ -126,6 +138,29 @@ experiment.record_event!('completed')
|
|
126
138
|
|
127
139
|
Note the `#record_event!` method also takes an optional user parameter should you want to define the user specifically in this case. It defaults to a user with the current_user_id defined in the Laboratory configuration.
|
128
140
|
|
141
|
+
### Temporarily overriding experiment variants
|
142
|
+
|
143
|
+
Sometimes, when QA'ing or developing an experiment, you'll want to easily switch between variants without having to jump into the console. This can be managed via a url parameter by adding the following snippet to your application controller (this example is for Rails, but a similar approach would work for other frameworks):
|
144
|
+
|
145
|
+
```ruby
|
146
|
+
|
147
|
+
around_action :override_laboratory_experiments!
|
148
|
+
|
149
|
+
def override_laboratory_experiments!
|
150
|
+
Laboratory::Experiment.override!(params[:exp])
|
151
|
+
yield
|
152
|
+
Laboratory::Experiment.clear_overrides!
|
153
|
+
end
|
154
|
+
```
|
155
|
+
|
156
|
+
This then allows you navigate to a urls like:
|
157
|
+
|
158
|
+
http://yourwebsite.com?exp[blue_button_ab_test]=variant_a
|
159
|
+
|
160
|
+
and
|
161
|
+
|
162
|
+
http://yourwebsite.com?exp[blue_button_ab_test]=control
|
163
|
+
|
129
164
|
### Using the Laboratory UI
|
130
165
|
|
131
166
|
It's easy to analyse and manage your experiment from the dashboard. In routes.rb, mount the dashboard behind your appropriate authentication layer (this example uses Devise):
|
@@ -208,8 +243,6 @@ user.variant_for_experiment(experiment) # Note: This returns nil if the user is
|
|
208
243
|
|
209
244
|
**Updating an experiment's variants**:
|
210
245
|
|
211
|
-
Note: This would wipe all users from the experiment.
|
212
|
-
|
213
246
|
```ruby
|
214
247
|
experiment = Laboratory::Experiment.find('blue_button_cta')
|
215
248
|
control = experiment.variants.find { |variant| variant.id == 'control' }
|
@@ -252,6 +285,11 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
252
285
|
|
253
286
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
254
287
|
|
288
|
+
### Todo List
|
289
|
+
|
290
|
+
- [ ] Test in a multi-threaded puma environment
|
291
|
+
- [ ] Test performance in a A/A test on production
|
292
|
+
|
255
293
|
## Contributing
|
256
294
|
|
257
295
|
Bug reports and pull requests are welcome on GitHub at https://github.com/butternutbox/laboratory. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to that.
|
data/Rakefile
CHANGED
data/bin/console
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'laboratory'
|
5
5
|
|
6
6
|
# You can add fixtures and/or initialization code here to make experimenting
|
7
7
|
# with your gem easier. You can also use a different console, if you like.
|
8
8
|
|
9
9
|
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
-
# require
|
10
|
+
# require 'pry'
|
11
11
|
# Pry.start
|
12
12
|
|
13
|
-
require
|
13
|
+
require 'irb'
|
14
14
|
IRB.start(__FILE__)
|
data/laboratory.gemspec
CHANGED
@@ -7,7 +7,7 @@ Gem::Specification.new do |spec|
|
|
7
7
|
spec.email = ['niall@butternutbox.com']
|
8
8
|
|
9
9
|
spec.summary = 'Laboratory: An A/B Testing and Feature Flag system for Ruby'
|
10
|
-
spec.description = '
|
10
|
+
spec.description = 'An A/B Testing and Feature Flag system for Ruby'
|
11
11
|
spec.homepage = 'https://github.com/butternutbox/laboratory'
|
12
12
|
spec.license = 'MIT'
|
13
13
|
spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
|
@@ -17,9 +17,12 @@ Gem::Specification.new do |spec|
|
|
17
17
|
spec.metadata['changelog_uri'] = 'https://github.com/butternutbox/laboratory/releases'
|
18
18
|
|
19
19
|
# Specify which files should be added to the gem when it is released.
|
20
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added
|
20
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added
|
21
|
+
# into git.
|
21
22
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
22
|
-
`git ls-files -z`.split("\x0").reject
|
23
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
24
|
+
f.match(%r{^(test|spec|features)/})
|
25
|
+
end
|
23
26
|
end
|
24
27
|
spec.bindir = 'exe'
|
25
28
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
@@ -28,7 +31,7 @@ Gem::Specification.new do |spec|
|
|
28
31
|
spec.add_dependency 'redis', '>= 2.1'
|
29
32
|
spec.add_dependency 'sinatra', '>= 1.2.6'
|
30
33
|
|
31
|
-
spec.add_development_dependency 'rack-test', '~> 1.1'
|
32
|
-
spec.add_development_dependency 'rspec', '~> 3.8'
|
33
34
|
spec.add_development_dependency 'fakeredis', '~> 0.8'
|
35
|
+
spec.add_development_dependency 'rack-test', '~> 1.1'
|
36
|
+
spec.add_development_dependency 'rspec', '~> 3.8'
|
34
37
|
end
|
@@ -1,23 +1,26 @@
|
|
1
1
|
module Laboratory
|
2
2
|
module Adapters
|
3
|
-
class RedisAdapter
|
3
|
+
class RedisAdapter # rubocop:disable Metrics/ClassLength
|
4
4
|
attr_reader :redis
|
5
5
|
|
6
|
-
ALL_EXPERIMENTS_KEYS_KEY = '
|
6
|
+
ALL_EXPERIMENTS_KEYS_KEY = 'LABORATORY_ALL_EXPERIMENT_KEYS'.freeze
|
7
7
|
|
8
8
|
def initialize(url:)
|
9
9
|
@redis = Redis.new(url: url)
|
10
|
-
|
11
|
-
if !redis.get(ALL_EXPERIMENTS_KEYS_KEY)
|
12
|
-
redis.set(ALL_EXPERIMENTS_KEYS_KEY, [])
|
13
|
-
end
|
14
10
|
end
|
15
11
|
|
16
12
|
def write(experiment)
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
13
|
+
key = redis_key(experiment_id: experiment.id)
|
14
|
+
json = experiment_to_json(experiment)
|
15
|
+
redis.set(key, json)
|
16
|
+
|
17
|
+
# Write to ALL_EXPERIMENTS_KEYS_KEY if it isn't already there.
|
18
|
+
experiment_ids = fetch_all_experiment_ids
|
19
|
+
|
20
|
+
unless experiment_ids.include?(experiment.id)
|
21
|
+
experiment_ids << experiment.id
|
22
|
+
end
|
23
|
+
|
21
24
|
redis.set(ALL_EXPERIMENTS_KEYS_KEY, experiment_ids.to_json)
|
22
25
|
end
|
23
26
|
|
@@ -26,11 +29,12 @@ module Laboratory
|
|
26
29
|
response = redis.get(key)
|
27
30
|
|
28
31
|
return nil if response.nil?
|
32
|
+
|
29
33
|
parse_json_to_experiment(JSON.parse(response))
|
30
34
|
end
|
31
35
|
|
32
36
|
def read_all
|
33
|
-
experiment_ids =
|
37
|
+
experiment_ids = fetch_all_experiment_ids
|
34
38
|
experiment_ids.map do |experiment_id|
|
35
39
|
read(experiment_id)
|
36
40
|
end
|
@@ -41,18 +45,27 @@ module Laboratory
|
|
41
45
|
redis.del(key)
|
42
46
|
|
43
47
|
# Remove from ALL_EXPERIMENTS_KEY_KEY
|
44
|
-
experiment_ids =
|
48
|
+
experiment_ids = fetch_all_experiment_ids
|
45
49
|
experiment_ids.delete(experiment_id)
|
46
50
|
redis.set(ALL_EXPERIMENTS_KEYS_KEY, experiment_ids.to_json)
|
47
51
|
end
|
48
52
|
|
49
53
|
def delete_all
|
50
|
-
experiment_ids =
|
54
|
+
experiment_ids = fetch_all_experiment_ids
|
51
55
|
experiment_ids.each { |experiment_id| delete(experiment_id) }
|
52
56
|
end
|
53
57
|
|
54
58
|
private
|
55
59
|
|
60
|
+
def fetch_all_experiment_ids
|
61
|
+
response = redis.get(ALL_EXPERIMENTS_KEYS_KEY)
|
62
|
+
if response
|
63
|
+
JSON.parse(response)
|
64
|
+
else
|
65
|
+
[]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
56
69
|
def redis_key(experiment_id:)
|
57
70
|
"laboratory_#{experiment_id}"
|
58
71
|
end
|
@@ -79,9 +92,11 @@ module Laboratory
|
|
79
92
|
|
80
93
|
def experiment_events_to_hash(events)
|
81
94
|
events.map do |event|
|
95
|
+
event_recordings =
|
96
|
+
experiment_event_recordings_to_hash(event.event_recordings)
|
82
97
|
{
|
83
98
|
id: event.id,
|
84
|
-
event_recordings:
|
99
|
+
event_recordings: event_recordings
|
85
100
|
}
|
86
101
|
end
|
87
102
|
end
|
@@ -127,9 +142,13 @@ module Laboratory
|
|
127
142
|
|
128
143
|
def parse_json_to_experiment_events(events_json)
|
129
144
|
events_json.map do |json|
|
145
|
+
event_recordings = parse_json_to_experiment_event_recordings(
|
146
|
+
json['event_recordings']
|
147
|
+
)
|
148
|
+
|
130
149
|
Experiment::Event.new(
|
131
150
|
id: json['id'],
|
132
|
-
event_recordings:
|
151
|
+
event_recordings: event_recordings
|
133
152
|
)
|
134
153
|
end
|
135
154
|
end
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module Laboratory
|
2
2
|
module Calculations
|
3
3
|
module ConfidenceLevel
|
4
|
-
def self.calculate(n1:, p1:, n2:, p2:)
|
4
|
+
def self.calculate(n1:, p1:, n2:, p2:) # rubocop:disable Naming/MethodParameterName
|
5
5
|
cvr1 = p1.fdiv(n1)
|
6
6
|
cvr2 = p2.fdiv(n2)
|
7
7
|
|
@@ -15,7 +15,7 @@ module Laboratory
|
|
15
15
|
percentage_from_z_score(-z).round(4)
|
16
16
|
end
|
17
17
|
|
18
|
-
def self.percentage_from_z_score(z)
|
18
|
+
def self.percentage_from_z_score(z) # rubocop:disable Naming/MethodParameterName, Metrics/AbcSize, Metrics/MethodLength
|
19
19
|
return 0 if z < -6.5
|
20
20
|
return 1 if z > 6.5
|
21
21
|
|
@@ -23,10 +23,13 @@ module Laboratory
|
|
23
23
|
sum = 0
|
24
24
|
term = 1
|
25
25
|
k = 0
|
26
|
+
const = 0.3989422804
|
26
27
|
|
27
28
|
loop_stop = Math.exp(-23)
|
28
|
-
while term.abs > loop_stop do
|
29
|
-
term =
|
29
|
+
while term.abs > loop_stop do # rubocop:disable Style/WhileUntilDo
|
30
|
+
term =
|
31
|
+
const * ((-1)**k) * (z**k) / (2 * k + 1) / (2**k) * (z**(k + 1)) / factk # rubocop:disable Layout/LineLength
|
32
|
+
|
30
33
|
sum += term
|
31
34
|
k += 1
|
32
35
|
factk *= k
|
@@ -6,33 +6,35 @@ module Laboratory
|
|
6
6
|
# n: Total population
|
7
7
|
# p: conversion percentage
|
8
8
|
|
9
|
-
def self.calculate(n1:, p1:, n2:, p2:)
|
10
|
-
|
11
|
-
|
9
|
+
def self.calculate(n1:, p1:, n2:, p2:) # rubocop:disable Metrics/AbcSize, Naming/MethodParameterName, Metrics/MethodLength
|
10
|
+
p1_float = p1.to_f
|
11
|
+
p2_float = p2.to_f
|
12
12
|
|
13
|
-
|
14
|
-
|
13
|
+
n1_float = n1.to_f
|
14
|
+
n2_float = n2.to_f
|
15
15
|
|
16
16
|
# Formula for standard error: root(pq/n) = root(p(1-p)/n)
|
17
|
-
|
18
|
-
|
17
|
+
s1_float = Math.sqrt(p1_float * (1 - p1_float) / n1_float)
|
18
|
+
s2_float = Math.sqrt(p2_float * (1 - p2_float) / n2_float)
|
19
19
|
|
20
|
-
# Formula for pooled error of the difference of the means:
|
21
|
-
#
|
22
|
-
pi = (
|
23
|
-
|
20
|
+
# Formula for pooled error of the difference of the means:
|
21
|
+
# root(pi*(1-pi)*(1/na+1/nc)
|
22
|
+
# pi = (xa + xc) / (na + nc)
|
23
|
+
pi = (p2_float * n2_float + p1_float * n1_float) / (n2_float + n1_float)
|
24
|
+
s_p = Math.sqrt(pi * (1 - pi) * (1 / n2_float + 1 / n1_float))
|
24
25
|
|
25
|
-
# Formula for unpooled error of the difference of the means:
|
26
|
-
|
26
|
+
# Formula for unpooled error of the difference of the means:
|
27
|
+
# root(sa**2/pi*a + sc**2/nc)
|
28
|
+
s_unp = Math.sqrt(s2_float**2 + s1_float**2)
|
27
29
|
|
28
30
|
# Boolean variable decides whether we can pool our variances
|
29
|
-
pooled =
|
31
|
+
pooled = s2_float / s1_float < 2 && s1_float / s2_float < 2
|
30
32
|
|
31
33
|
# Assign standard error either the pooled or unpooled variance
|
32
34
|
se = pooled ? s_p : s_unp
|
33
35
|
|
34
36
|
# Calculate z-score
|
35
|
-
z_score = (
|
37
|
+
z_score = (p2_float - p1_float) / se
|
36
38
|
|
37
39
|
z_score.round(4)
|
38
40
|
end
|
@@ -17,8 +17,9 @@ module Laboratory
|
|
17
17
|
end
|
18
18
|
|
19
19
|
def performance_delta_between_highest_and_lowest
|
20
|
-
numerator =
|
21
|
-
conversion_rate_for_variant(
|
20
|
+
numerator =
|
21
|
+
(conversion_rate_for_variant(highest_performing_variant) -
|
22
|
+
conversion_rate_for_variant(lowest_performing_variant))
|
22
23
|
denominator = conversion_rate_for_variant(lowest_performing_variant)
|
23
24
|
numerator.fdiv(denominator).round(2)
|
24
25
|
end
|
@@ -57,9 +58,10 @@ module Laboratory
|
|
57
58
|
end
|
58
59
|
|
59
60
|
def sorted_variants
|
60
|
-
relevant_variants.sort_by
|
61
|
-
|
62
|
-
|
61
|
+
relevant_variants.sort_by do |variant|
|
62
|
+
# Order in descending order
|
63
|
+
-1 * conversion_rate_for_variant(variant)
|
64
|
+
end
|
63
65
|
end
|
64
66
|
|
65
67
|
def event_for_variant(variant)
|
@@ -26,7 +26,11 @@ module Laboratory
|
|
26
26
|
def changeset
|
27
27
|
set = {}
|
28
28
|
set[:id] = [_original_id, id] if _original_id != id
|
29
|
-
|
29
|
+
|
30
|
+
if _original_percentage != percentage
|
31
|
+
set[:percentage] = [_original_percentage, percentage]
|
32
|
+
end
|
33
|
+
|
30
34
|
set
|
31
35
|
end
|
32
36
|
end
|
@@ -1,5 +1,19 @@
|
|
1
1
|
module Laboratory
|
2
|
-
class Experiment
|
2
|
+
class Experiment # rubocop:disable Metrics/ClassLength
|
3
|
+
class << self
|
4
|
+
def overrides
|
5
|
+
@overrides || {}
|
6
|
+
end
|
7
|
+
|
8
|
+
def override!(overrides)
|
9
|
+
@overrides = overrides
|
10
|
+
end
|
11
|
+
|
12
|
+
def clear_overrides!
|
13
|
+
@overrides = {}
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
3
17
|
class UserNotInExperimentError < StandardError; end
|
4
18
|
class ClashingExperimentIdError < StandardError; end
|
5
19
|
class MissingExperimentIdError < StandardError; end
|
@@ -15,7 +29,7 @@ module Laboratory
|
|
15
29
|
:changelog
|
16
30
|
)
|
17
31
|
|
18
|
-
def initialize(id:, variants:, algorithm: Algorithms::Random, changelog: [])
|
32
|
+
def initialize(id:, variants:, algorithm: Algorithms::Random, changelog: []) # rubocop:disable Metrics/MethodLength
|
19
33
|
@id = id
|
20
34
|
@algorithm = algorithm
|
21
35
|
@changelog = changelog
|
@@ -24,7 +38,7 @@ module Laboratory
|
|
24
38
|
# This also helps when decoding from adapters
|
25
39
|
|
26
40
|
@variants =
|
27
|
-
if variants.all? { |variant| variant.instance_of?(
|
41
|
+
if variants.all? { |variant| variant.instance_of?(Experiment::Variant) }
|
28
42
|
variants
|
29
43
|
elsif variants.all? { |variant| variant.instance_of?(Hash) }
|
30
44
|
variants.map do |variant|
|
@@ -83,9 +97,15 @@ module Laboratory
|
|
83
97
|
save
|
84
98
|
end
|
85
99
|
|
86
|
-
def variant(user: Laboratory.config.current_user)
|
87
|
-
|
88
|
-
|
100
|
+
def variant(user: Laboratory.config.current_user) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
101
|
+
return variant_overridden_with if overridden?
|
102
|
+
|
103
|
+
selected_variant =
|
104
|
+
variants.find do |variant|
|
105
|
+
variant.participant_ids.include?(user.id)
|
106
|
+
end
|
107
|
+
|
108
|
+
return selected_variant unless selected_variant.nil?
|
89
109
|
|
90
110
|
variant = algorithm.pick!(variants)
|
91
111
|
variant.add_participant(user)
|
@@ -110,7 +130,7 @@ module Laboratory
|
|
110
130
|
variant
|
111
131
|
end
|
112
132
|
|
113
|
-
def record_event!(event_id, user: Laboratory.config.current_user)
|
133
|
+
def record_event!(event_id, user: Laboratory.config.current_user) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
114
134
|
variant = variants.find { |s| s.participant_ids.include?(user.id) }
|
115
135
|
raise UserNotInExperimentError unless variant
|
116
136
|
|
@@ -139,6 +159,7 @@ module Laboratory
|
|
139
159
|
|
140
160
|
def save
|
141
161
|
raise errors.first unless valid?
|
162
|
+
|
142
163
|
unless changeset.empty?
|
143
164
|
changelog_item = Laboratory::Experiment::ChangelogItem.new(
|
144
165
|
changes: changeset,
|
@@ -151,7 +172,7 @@ module Laboratory
|
|
151
172
|
Laboratory.config.adapter.write(self)
|
152
173
|
end
|
153
174
|
|
154
|
-
def valid?
|
175
|
+
def valid? # rubocop:disable Metrics/AbcSize
|
155
176
|
valid_variants =
|
156
177
|
variants.all? do |variant|
|
157
178
|
!variant.id.nil? && !variant.percentage.nil?
|
@@ -165,14 +186,27 @@ module Laboratory
|
|
165
186
|
|
166
187
|
private
|
167
188
|
|
168
|
-
def
|
189
|
+
def overridden?
|
190
|
+
self.class.overrides.key?(id) && !variant_overridden_with.nil?
|
191
|
+
end
|
192
|
+
|
193
|
+
def variant_overridden_with
|
194
|
+
variants.find { |v| v.id == self.class.overrides[id] }
|
195
|
+
end
|
196
|
+
|
197
|
+
def changeset # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
169
198
|
set = {}
|
170
199
|
set[:id] = [_original_id, id] if _original_id != id
|
171
|
-
set[:algorithm] = [_original_algorithm, algorithm] if _original_algorithm != algorithm
|
172
200
|
|
173
|
-
|
174
|
-
|
175
|
-
|
201
|
+
if _original_algorithm != algorithm
|
202
|
+
set[:algorithm] = [_original_algorithm, algorithm]
|
203
|
+
end
|
204
|
+
|
205
|
+
variants_changeset =
|
206
|
+
variants.map do |variant|
|
207
|
+
{ variant.id => variant.changeset }
|
208
|
+
end
|
209
|
+
|
176
210
|
variants_changeset.reject! do |change|
|
177
211
|
change.values.all?(&:empty?)
|
178
212
|
end
|
@@ -181,23 +215,25 @@ module Laboratory
|
|
181
215
|
set
|
182
216
|
end
|
183
217
|
|
184
|
-
def errors
|
218
|
+
def errors # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
185
219
|
errors = []
|
186
220
|
|
187
|
-
missing_variant_ids =
|
188
|
-
|
189
|
-
|
221
|
+
missing_variant_ids =
|
222
|
+
variants.any? do |variant|
|
223
|
+
variant.id.nil?
|
224
|
+
end
|
190
225
|
|
191
|
-
missing_variant_percentages =
|
192
|
-
|
193
|
-
|
226
|
+
missing_variant_percentages =
|
227
|
+
variants.any? do |variant|
|
228
|
+
variant.percentage.nil?
|
229
|
+
end
|
194
230
|
|
195
231
|
incorrect_percentage_total = variants.map(&:percentage).sum != 100
|
196
232
|
|
197
233
|
errors << MissingExperimentIdError if id.nil?
|
198
234
|
errors << MissingExperimentAlgorithmError if algorithm.nil?
|
199
235
|
errors << MissingExperimentVariantIdError if missing_variant_ids
|
200
|
-
errors << MissingExperimentVariantPercentageError if missing_variant_percentages
|
236
|
+
errors << MissingExperimentVariantPercentageError if missing_variant_percentages # rubocop:disable Layout/LineLength
|
201
237
|
errors << IncorrectPercentageTotalError if incorrect_percentage_total
|
202
238
|
|
203
239
|
errors
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module Laboratory
|
2
2
|
module UIHelpers
|
3
3
|
def url(*path_parts)
|
4
|
-
[
|
4
|
+
[path_prefix, path_parts].join('/').squeeze('/')
|
5
5
|
end
|
6
6
|
|
7
7
|
def path_prefix
|
@@ -9,26 +9,31 @@ module Laboratory
|
|
9
9
|
end
|
10
10
|
|
11
11
|
def experiment_url(experiment)
|
12
|
-
url('experiments', experiment.id, 'edit')
|
12
|
+
url('experiments', CGI.escape(experiment.id), 'edit')
|
13
13
|
end
|
14
14
|
|
15
15
|
def update_percentages_url(experiment)
|
16
|
-
url('experiments', experiment.id, 'update_percentages')
|
16
|
+
url('experiments', CGI.escape(experiment.id), 'update_percentages')
|
17
17
|
end
|
18
18
|
|
19
19
|
def assign_users_to_variant_url(experiment)
|
20
|
-
url('experiments', experiment.id, 'assign_users')
|
20
|
+
url('experiments', CGI.escape(experiment.id), 'assign_users')
|
21
21
|
end
|
22
22
|
|
23
23
|
def reset_experiment_url(experiment)
|
24
|
-
url('experiments', experiment.id, 'reset')
|
24
|
+
url('experiments', CGI.escape(experiment.id), 'reset')
|
25
25
|
end
|
26
26
|
|
27
27
|
def analysis_summary(experiment, event_id)
|
28
|
+
return if experiment.variants.length < 2
|
29
|
+
|
28
30
|
analysis = experiment.analysis_summary_for(event_id)
|
29
|
-
return if analysis.highest_performing_variant == analysis.lowest_performing_variant
|
30
31
|
|
31
|
-
"#{analysis.highest_performing_variant.id} is performing
|
32
|
+
"#{analysis.highest_performing_variant.id} is performing" \
|
33
|
+
" #{analysis.performance_delta_between_highest_and_lowest * 100}%" \
|
34
|
+
" better than #{analysis.lowest_performing_variant.id}. I'm" \
|
35
|
+
" #{analysis.confidence_level_in_performance_delta * 100}% certain of" \
|
36
|
+
' this.'
|
32
37
|
end
|
33
38
|
end
|
34
39
|
end
|
data/lib/laboratory/ui.rb
CHANGED
@@ -19,13 +19,13 @@ module Laboratory
|
|
19
19
|
end
|
20
20
|
|
21
21
|
get '/experiments/:id/edit' do
|
22
|
-
@experiment = Laboratory::Experiment.find(params[:id])
|
22
|
+
@experiment = Laboratory::Experiment.find(CGI.unescape(params[:id]))
|
23
23
|
erb :edit
|
24
24
|
end
|
25
25
|
|
26
26
|
# params = {variants: { control => 40, variant_a => 60 }}
|
27
27
|
post '/experiments/:id/update_percentages' do
|
28
|
-
experiment = Laboratory::Experiment.find(params[:id])
|
28
|
+
experiment = Laboratory::Experiment.find(CGI.unescape(params[:id]))
|
29
29
|
|
30
30
|
params[:variants].each do |variant_id, percentage|
|
31
31
|
variant = experiment.variants.find { |v| v.id == variant_id }
|
@@ -38,7 +38,7 @@ module Laboratory
|
|
38
38
|
|
39
39
|
# params = {variant_id: 'control', user_ids: []}
|
40
40
|
post '/experiments/:id/assign_users' do
|
41
|
-
experiment = Laboratory::Experiment.find(params[:id])
|
41
|
+
experiment = Laboratory::Experiment.find(CGI.unescape(params[:id]))
|
42
42
|
variant = experiment.variants.find { |v| v.id == params[:variant_id] }
|
43
43
|
user_ids = params[:user_ids].split("\r\n")
|
44
44
|
|
@@ -51,7 +51,7 @@ module Laboratory
|
|
51
51
|
end
|
52
52
|
|
53
53
|
post '/experiments/:id/reset' do
|
54
|
-
experiment = Laboratory::Experiment.find(params[:id])
|
54
|
+
experiment = Laboratory::Experiment.find(CGI.unescape(params[:id]))
|
55
55
|
experiment.reset
|
56
56
|
redirect experiment_url(experiment)
|
57
57
|
end
|
data/lib/laboratory/version.rb
CHANGED