laboratory 0.1.1 → 0.1.2
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.
- 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
|
+

|
23
|
+
|
24
|
+
### Editting an experiment
|
25
|
+
|
26
|
+

|
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