laboratory 0.1.0 → 0.1.1
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/Gemfile.lock +17 -1
- data/README.md +16 -19
- data/laboratory.gemspec +2 -0
- data/lib/laboratory/adapters/redis_adapter.rb +4 -6
- data/lib/laboratory/calculations/confidence_level.rb +40 -0
- data/lib/laboratory/calculations/z_score.rb +41 -0
- data/lib/laboratory/experiment/analysis_summary.rb +72 -0
- data/lib/laboratory/experiment/changelog_item.rb +2 -3
- data/lib/laboratory/experiment/variant.rb +17 -1
- data/lib/laboratory/experiment.rb +60 -67
- data/lib/laboratory/ui/helpers.rb +34 -0
- data/lib/laboratory/ui/public/reset.css +48 -0
- data/lib/laboratory/ui/public/style.css +102 -0
- data/lib/laboratory/ui/views/_experiment.erb +86 -0
- data/lib/laboratory/ui/views/edit.erb +61 -0
- data/lib/laboratory/ui/views/index.erb +8 -0
- data/lib/laboratory/ui/views/layout.erb +24 -0
- data/lib/laboratory/ui.rb +59 -0
- data/lib/laboratory/version.rb +1 -1
- data/lib/laboratory.rb +3 -0
- metadata +41 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '028eeacc314798c88a5a6b5b3db37754751dbe90e18907c130ff2f517685f09e'
|
4
|
+
data.tar.gz: 526145857a0786d662f06b8204c6854c5618f3150286eb19e8ae1e2022e1b17e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5b7942166dc0dca4cc5c900ddde6c98dc9734eb390c776e309a24598eb10b7e1f07f518d941b4b47aeea81928ffb925176bb199d804cdc5ad061661c8bb6e98f
|
7
|
+
data.tar.gz: f08de0e7e5d841cb6d6be5cc3be9de9c34f9f1f85c0173e95f76a786c4a2eefebb05322b3a2e6b65a4643dd5ae9c8ff7ead830e98573f38b5edb8cafca170365
|
data/Gemfile.lock
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
laboratory (0.1.
|
4
|
+
laboratory (0.1.1)
|
5
5
|
redis (>= 2.1)
|
6
|
+
sinatra (>= 1.2.6)
|
6
7
|
|
7
8
|
GEM
|
8
9
|
remote: https://rubygems.org/
|
@@ -10,6 +11,13 @@ GEM
|
|
10
11
|
diff-lcs (1.3)
|
11
12
|
fakeredis (0.8.0)
|
12
13
|
redis (~> 4.1)
|
14
|
+
mustermann (1.1.1)
|
15
|
+
ruby2_keywords (~> 0.0.1)
|
16
|
+
rack (2.2.2)
|
17
|
+
rack-protection (2.0.8.1)
|
18
|
+
rack
|
19
|
+
rack-test (1.1.0)
|
20
|
+
rack (>= 1.0, < 3)
|
13
21
|
rake (12.3.3)
|
14
22
|
redis (4.1.3)
|
15
23
|
rspec (3.9.0)
|
@@ -25,6 +33,13 @@ GEM
|
|
25
33
|
diff-lcs (>= 1.2.0, < 2.0)
|
26
34
|
rspec-support (~> 3.9.0)
|
27
35
|
rspec-support (3.9.2)
|
36
|
+
ruby2_keywords (0.0.2)
|
37
|
+
sinatra (2.0.8.1)
|
38
|
+
mustermann (~> 1.0)
|
39
|
+
rack (~> 2.0)
|
40
|
+
rack-protection (= 2.0.8.1)
|
41
|
+
tilt (~> 2.0)
|
42
|
+
tilt (2.0.10)
|
28
43
|
|
29
44
|
PLATFORMS
|
30
45
|
ruby
|
@@ -32,6 +47,7 @@ PLATFORMS
|
|
32
47
|
DEPENDENCIES
|
33
48
|
fakeredis (~> 0.8)
|
34
49
|
laboratory!
|
50
|
+
rack-test (~> 1.1)
|
35
51
|
rake (~> 12.0)
|
36
52
|
rspec (~> 3.8)
|
37
53
|
|
data/README.md
CHANGED
@@ -11,12 +11,14 @@ Laboratory is an A/B testing and Feature Flag framework for Rails. It's focused
|
|
11
11
|
* Simplicity, while not being simplisitic
|
12
12
|
* Laboratory comes with a single A/B test algorithm and single storage adapter built-in, which we think will cover 99% of use cases for A/B testing and Feature Flagging. If you're in that 1%, it's easy to extend Laboratory to add custom Algorithms and Storage mechanisms.
|
13
13
|
|
14
|
+
Laboratory builds upon great work from other gems, in particular [Split](https://github.com/splitrb/split).
|
15
|
+
|
14
16
|
## Installation
|
15
17
|
|
16
18
|
Add this line to your application's Gemfile:
|
17
19
|
|
18
20
|
```ruby
|
19
|
-
gem 'laboratory'
|
21
|
+
gem 'laboratory', require: 'laboratory/ui'
|
20
22
|
```
|
21
23
|
|
22
24
|
And then execute:
|
@@ -35,7 +37,7 @@ Or install it yourself as:
|
|
35
37
|
In an initializer (`app/config/initializers/laboratory.rb`), define the adapter you are going to use with Laboratory. Laboratory supports Redis out of the box, as it the recommended adapter:
|
36
38
|
|
37
39
|
```ruby
|
38
|
-
Laboratory
|
40
|
+
Laboratory.config.adapter = Laboratory::Adapters::RedisAdapter.new(url: 'redis://localhost:6789') # Adjust to your redis URL.
|
39
41
|
```
|
40
42
|
|
41
43
|
### Defining your current_user_id & actor
|
@@ -51,11 +53,11 @@ class ApplicationController
|
|
51
53
|
before_action :set_laboratory_actor
|
52
54
|
|
53
55
|
def set_laboratory_current_user_id
|
54
|
-
Laboratory
|
56
|
+
Laboratory.config.current_user_id = your_current_user_id
|
55
57
|
end
|
56
58
|
|
57
59
|
def set_laboratory_actor
|
58
|
-
Laboratory
|
60
|
+
Laboratory.config.actor = 'Tom Jones'
|
59
61
|
end
|
60
62
|
```
|
61
63
|
|
@@ -144,7 +146,7 @@ It's common to trigger analytics events upon common actions like a user being as
|
|
144
146
|
**When a user gets assigned to a variant**:
|
145
147
|
|
146
148
|
```ruby
|
147
|
-
Laboratory
|
149
|
+
Laboratory.config.on_assignment_to_variant = -> (experiment, variant, user) {
|
148
150
|
...
|
149
151
|
}
|
150
152
|
```
|
@@ -152,7 +154,7 @@ Laboratory::Config.on_assignment_to_variant = -> (experiment, variant, user) {
|
|
152
154
|
**When an event is recorded**:
|
153
155
|
|
154
156
|
```ruby
|
155
|
-
Laboratory
|
157
|
+
Laboratory.config.on_event_recorded = -> (experiment, variant, user, event) {
|
156
158
|
...
|
157
159
|
}
|
158
160
|
```
|
@@ -210,18 +212,13 @@ Note: This would wipe all users from the experiment.
|
|
210
212
|
|
211
213
|
```ruby
|
212
214
|
experiment = Laboratory::Experiment.find('blue_button_cta')
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
percentage: 60
|
221
|
-
}
|
222
|
-
]
|
223
|
-
|
224
|
-
experiment.update(variants: new_variants)
|
215
|
+
control = experiment.variants.find { |variant| variant.id == 'control' }
|
216
|
+
variant_a = experiment.variants.find { |variant| variant.id == 'variant_a' }
|
217
|
+
|
218
|
+
control.percentage = 30
|
219
|
+
variant_a.percentage = 70
|
220
|
+
|
221
|
+
experiment.save
|
225
222
|
```
|
226
223
|
|
227
224
|
**Finding the current user**:
|
@@ -229,7 +226,7 @@ experiment.update(variants: new_variants)
|
|
229
226
|
The following will return a Laboratory::User object with the user id matching the current_user_id defined in the Laboratory configuration.
|
230
227
|
|
231
228
|
```ruby
|
232
|
-
Laboratory
|
229
|
+
Laboratory.config.current_user
|
233
230
|
```
|
234
231
|
|
235
232
|
|
data/laboratory.gemspec
CHANGED
@@ -26,7 +26,9 @@ Gem::Specification.new do |spec|
|
|
26
26
|
spec.require_paths = ['lib']
|
27
27
|
|
28
28
|
spec.add_dependency 'redis', '>= 2.1'
|
29
|
+
spec.add_dependency 'sinatra', '>= 1.2.6'
|
29
30
|
|
31
|
+
spec.add_development_dependency 'rack-test', '~> 1.1'
|
30
32
|
spec.add_development_dependency 'rspec', '~> 3.8'
|
31
33
|
spec.add_development_dependency 'fakeredis', '~> 0.8'
|
32
34
|
end
|
@@ -15,7 +15,6 @@ module Laboratory
|
|
15
15
|
|
16
16
|
def write(experiment)
|
17
17
|
redis.set(redis_key(experiment_id: experiment.id), experiment_to_json(experiment))
|
18
|
-
|
19
18
|
# Write to ALL_EXPERIMENTS_KEY_KEY if it isn't already there.
|
20
19
|
experiment_ids = JSON.parse(redis.get(ALL_EXPERIMENTS_KEYS_KEY))
|
21
20
|
experiment_ids << experiment.id unless experiment_ids.include?(experiment.id)
|
@@ -118,11 +117,10 @@ module Laboratory
|
|
118
117
|
|
119
118
|
def parse_json_to_experiment_changelog_items(changelog_json)
|
120
119
|
changelog_json.map do |json|
|
121
|
-
Experiment::
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
actor: json[:actor]
|
120
|
+
Experiment::ChangelogItem.new(
|
121
|
+
changes: json['changes'],
|
122
|
+
timestamp: json['timestamp'],
|
123
|
+
actor: json['actor']
|
126
124
|
)
|
127
125
|
end
|
128
126
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Laboratory
|
2
|
+
module Calculations
|
3
|
+
module ConfidenceLevel
|
4
|
+
def self.calculate(n1:, p1:, n2:, p2:)
|
5
|
+
cvr1 = p1.fdiv(n1)
|
6
|
+
cvr2 = p2.fdiv(n2)
|
7
|
+
|
8
|
+
z = ZScore.calculate(
|
9
|
+
n1: n1,
|
10
|
+
p1: cvr1,
|
11
|
+
n2: n2,
|
12
|
+
p2: cvr2
|
13
|
+
)
|
14
|
+
|
15
|
+
percentage_from_z_score(-z).round(4)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.percentage_from_z_score(z)
|
19
|
+
return 0 if z < -6.5
|
20
|
+
return 1 if z > 6.5
|
21
|
+
|
22
|
+
factk = 1
|
23
|
+
sum = 0
|
24
|
+
term = 1
|
25
|
+
k = 0
|
26
|
+
|
27
|
+
loop_stop = Math.exp(-23)
|
28
|
+
while term.abs > loop_stop do
|
29
|
+
term = 0.3989422804 * ((-1)**k) * (z**k) / (2 * k + 1) / (2**k) * (z**(k + 1)) / factk
|
30
|
+
sum += term
|
31
|
+
k += 1
|
32
|
+
factk *= k
|
33
|
+
end
|
34
|
+
|
35
|
+
sum += 0.5
|
36
|
+
1 - sum
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Laboratory
|
2
|
+
module Calculations
|
3
|
+
module ZScore
|
4
|
+
include Math
|
5
|
+
|
6
|
+
# n: Total population
|
7
|
+
# p: conversion percentage
|
8
|
+
|
9
|
+
def self.calculate(n1:, p1:, n2:, p2:)
|
10
|
+
p_1 = p1.to_f
|
11
|
+
p_2 = p2.to_f
|
12
|
+
|
13
|
+
n_1 = n1.to_f
|
14
|
+
n_2 = n2.to_f
|
15
|
+
|
16
|
+
# Formula for standard error: root(pq/n) = root(p(1-p)/n)
|
17
|
+
s_1 = Math.sqrt(p_1 * (1 - p_1) / n_1)
|
18
|
+
s_2 = Math.sqrt(p_2 * (1 - p_2) / n_2)
|
19
|
+
|
20
|
+
# Formula for pooled error of the difference of the means: root(π*(1-π)*(1/na+1/nc)
|
21
|
+
# π = (xa + xc) / (na + nc)
|
22
|
+
pi = (p_2 * n_2 + p_1 * n_1) / (n_2 + n_1)
|
23
|
+
s_p = Math.sqrt(pi * (1 - pi) * (1 / n_2 + 1 / n_1))
|
24
|
+
|
25
|
+
# Formula for unpooled error of the difference of the means: root(sa**2/na + sc**2/nc)
|
26
|
+
s_unp = Math.sqrt(s_2**2 + s_1**2)
|
27
|
+
|
28
|
+
# Boolean variable decides whether we can pool our variances
|
29
|
+
pooled = s_2 / s_1 < 2 && s_1 / s_2 < 2
|
30
|
+
|
31
|
+
# Assign standard error either the pooled or unpooled variance
|
32
|
+
se = pooled ? s_p : s_unp
|
33
|
+
|
34
|
+
# Calculate z-score
|
35
|
+
z_score = (p_2 - p_1) / (se)
|
36
|
+
|
37
|
+
z_score.round(4)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module Laboratory
|
2
|
+
class Experiment
|
3
|
+
class AnalysisSummary
|
4
|
+
attr_reader :experiment, :event_id
|
5
|
+
|
6
|
+
def initialize(experiment, event_id)
|
7
|
+
@experiment = experiment
|
8
|
+
@event_id = event_id
|
9
|
+
end
|
10
|
+
|
11
|
+
def highest_performing_variant
|
12
|
+
sorted_variants.first
|
13
|
+
end
|
14
|
+
|
15
|
+
def lowest_performing_variant
|
16
|
+
sorted_variants.last
|
17
|
+
end
|
18
|
+
|
19
|
+
def performance_delta_between_highest_and_lowest
|
20
|
+
numerator = (conversion_rate_for_variant(highest_performing_variant) -
|
21
|
+
conversion_rate_for_variant(lowest_performing_variant))
|
22
|
+
denominator = conversion_rate_for_variant(lowest_performing_variant)
|
23
|
+
numerator.fdiv(denominator).round(2)
|
24
|
+
end
|
25
|
+
|
26
|
+
def confidence_level_in_performance_delta
|
27
|
+
Laboratory::Calculations::ConfidenceLevel.calculate(
|
28
|
+
n1: participant_count_for_variant(lowest_performing_variant),
|
29
|
+
p1: event_total_count_for_variant(lowest_performing_variant),
|
30
|
+
n2: participant_count_for_variant(highest_performing_variant),
|
31
|
+
p2: event_total_count_for_variant(highest_performing_variant)
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def participant_count_for_variant(variant)
|
38
|
+
variant.participant_ids.count
|
39
|
+
end
|
40
|
+
|
41
|
+
def event_total_count_for_variant(variant)
|
42
|
+
event = event_for_variant(variant)
|
43
|
+
event.event_recordings.count
|
44
|
+
end
|
45
|
+
|
46
|
+
def conversion_rate_for_variant(variant)
|
47
|
+
event_total_count_for_variant(variant)
|
48
|
+
.fdiv(participant_count_for_variant(variant))
|
49
|
+
end
|
50
|
+
|
51
|
+
def relevant_variants
|
52
|
+
experiment.variants.select do |variant|
|
53
|
+
variant.events.any? do |event|
|
54
|
+
event.id == event_id
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def sorted_variants
|
60
|
+
relevant_variants.sort_by { |variant|
|
61
|
+
conversion_rate_for_variant(variant)
|
62
|
+
}.reverse
|
63
|
+
end
|
64
|
+
|
65
|
+
def event_for_variant(variant)
|
66
|
+
variant.events.find do |event|
|
67
|
+
event.id == event_id
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -1,10 +1,9 @@
|
|
1
1
|
module Laboratory
|
2
2
|
class Experiment
|
3
3
|
class ChangelogItem
|
4
|
-
attr_reader :
|
4
|
+
attr_reader :changes, :timestamp, :actor
|
5
5
|
|
6
|
-
def initialize(
|
7
|
-
@action = action
|
6
|
+
def initialize(changes:, timestamp:, actor:)
|
8
7
|
@changes = changes
|
9
8
|
@timestamp = timestamp
|
10
9
|
@actor = actor
|
@@ -1,18 +1,34 @@
|
|
1
1
|
module Laboratory
|
2
2
|
class Experiment
|
3
3
|
class Variant
|
4
|
-
|
4
|
+
attr_accessor :id, :percentage
|
5
|
+
attr_reader(
|
6
|
+
:_original_id,
|
7
|
+
:_original_percentage,
|
8
|
+
:participant_ids,
|
9
|
+
:events
|
10
|
+
)
|
5
11
|
|
6
12
|
def initialize(id:, percentage:, participant_ids: [], events: [])
|
7
13
|
@id = id
|
8
14
|
@percentage = percentage
|
9
15
|
@participant_ids = participant_ids
|
10
16
|
@events = events
|
17
|
+
|
18
|
+
@_original_id = id
|
19
|
+
@_original_percentage = percentage
|
11
20
|
end
|
12
21
|
|
13
22
|
def add_participant(user)
|
14
23
|
participant_ids << user.id
|
15
24
|
end
|
25
|
+
|
26
|
+
def changeset
|
27
|
+
set = {}
|
28
|
+
set[:id] = [_original_id, id] if _original_id != id
|
29
|
+
set[:percentage] = [_original_percentage, percentage] if _original_percentage != percentage
|
30
|
+
set
|
31
|
+
end
|
16
32
|
end
|
17
33
|
end
|
18
34
|
end
|
@@ -7,7 +7,13 @@ module Laboratory
|
|
7
7
|
class InvalidExperimentVariantFormatError < StandardError; end
|
8
8
|
class IncorrectPercentageTotalError < StandardError; end
|
9
9
|
|
10
|
-
|
10
|
+
attr_accessor :id, :algorithm
|
11
|
+
attr_reader(
|
12
|
+
:_original_id,
|
13
|
+
:_original_algorithm,
|
14
|
+
:variants,
|
15
|
+
:changelog
|
16
|
+
)
|
11
17
|
|
12
18
|
def initialize(id:, variants:, algorithm: Algorithms::Random, changelog: [])
|
13
19
|
@id = id
|
@@ -30,6 +36,9 @@ module Laboratory
|
|
30
36
|
)
|
31
37
|
end
|
32
38
|
end
|
39
|
+
|
40
|
+
@_original_id = id
|
41
|
+
@_original_algorithm = algorithm
|
33
42
|
end
|
34
43
|
|
35
44
|
def self.all
|
@@ -39,21 +48,13 @@ module Laboratory
|
|
39
48
|
def self.create(id:, variants:, algorithm: Algorithms::Random)
|
40
49
|
raise ClashingExperimentIdError if find(id)
|
41
50
|
|
42
|
-
changelog_item = Laboratory::Experiment::ChangelogItem.new(
|
43
|
-
action: :create,
|
44
|
-
changes: [],
|
45
|
-
timestamp: Time.now,
|
46
|
-
actor: Laboratory::Config.actor
|
47
|
-
)
|
48
|
-
|
49
51
|
experiment = Experiment.new(
|
50
52
|
id: id,
|
51
53
|
variants: variants,
|
52
|
-
algorithm: algorithm
|
53
|
-
changelog: [changelog_item]
|
54
|
+
algorithm: algorithm
|
54
55
|
)
|
55
56
|
|
56
|
-
experiment.
|
57
|
+
experiment.save
|
57
58
|
experiment
|
58
59
|
end
|
59
60
|
|
@@ -65,62 +66,23 @@ module Laboratory
|
|
65
66
|
find(id) || create(id: id, variants: variants, algorithm: algorithm)
|
66
67
|
end
|
67
68
|
|
68
|
-
def update(attrs)
|
69
|
-
# delete previous key if valid? passes below.
|
70
|
-
old_id = id
|
71
|
-
|
72
|
-
# Diff changes
|
73
|
-
|
74
|
-
current_hash = {
|
75
|
-
id: id,
|
76
|
-
variants: variants.map { |variant|
|
77
|
-
{
|
78
|
-
id: variant.id,
|
79
|
-
percentage: variant.percentage
|
80
|
-
}
|
81
|
-
},
|
82
|
-
algorithm: algorithm
|
83
|
-
}
|
84
|
-
|
85
|
-
updated_variants_subhash = attrs[:variants]&.map do |variant|
|
86
|
-
{
|
87
|
-
id: variant[:id],
|
88
|
-
percentage: variant[:percentage]
|
89
|
-
}
|
90
|
-
end
|
91
|
-
|
92
|
-
updated_hash = {
|
93
|
-
id: attrs[:id] || id,
|
94
|
-
variants: updated_variants_subhash || current_hash[:variants],
|
95
|
-
algorithm: attrs[:algorithm] || algorithm
|
96
|
-
}
|
97
|
-
|
98
|
-
changes = current_hash.to_a - updated_hash.to_a
|
99
|
-
@id = attrs[:id] if !attrs[:id].nil?
|
100
|
-
@variants = attrs[:variants] if !attrs[:variants].nil?
|
101
|
-
@algorithm = attrs[:algorithm] if !attrs[:algorithm].nil?
|
102
|
-
|
103
|
-
raise errors.first unless valid?
|
104
|
-
|
105
|
-
changelog_item = Laboratory::Experiment::ChangelogItem.new(
|
106
|
-
action: :update,
|
107
|
-
changes: changes,
|
108
|
-
timestamp: Time.now,
|
109
|
-
actor: Laboratory.config.actor
|
110
|
-
)
|
111
|
-
|
112
|
-
@changelog << changelog_item
|
113
|
-
|
114
|
-
Laboratory.config.adapter.delete(old_id)
|
115
|
-
write!
|
116
|
-
self
|
117
|
-
end
|
118
|
-
|
119
69
|
def delete
|
120
70
|
Laboratory.config.adapter.delete(id)
|
121
71
|
nil
|
122
72
|
end
|
123
73
|
|
74
|
+
def reset
|
75
|
+
@variants = variants.map do |variant|
|
76
|
+
Variant.new(
|
77
|
+
id: variant.id,
|
78
|
+
percentage: variant.percentage,
|
79
|
+
participant_ids: [],
|
80
|
+
events: []
|
81
|
+
)
|
82
|
+
end
|
83
|
+
save
|
84
|
+
end
|
85
|
+
|
124
86
|
def variant(user: Laboratory.config.current_user)
|
125
87
|
selected_variant = variants.find { |variant| variant.participant_ids.include?(user.id)}
|
126
88
|
return selected_variant if !selected_variant.nil?
|
@@ -130,7 +92,7 @@ module Laboratory
|
|
130
92
|
|
131
93
|
Laboratory::Config.on_assignment_to_variant&.call(self, variant, user)
|
132
94
|
|
133
|
-
|
95
|
+
save
|
134
96
|
variant
|
135
97
|
end
|
136
98
|
|
@@ -144,7 +106,7 @@ module Laboratory
|
|
144
106
|
|
145
107
|
Laboratory::Config.on_assignment_to_variant&.call(self, variant, user)
|
146
108
|
|
147
|
-
|
109
|
+
save
|
148
110
|
variant
|
149
111
|
end
|
150
112
|
|
@@ -154,7 +116,7 @@ module Laboratory
|
|
154
116
|
|
155
117
|
maybe_event = variant.events.find { |event| event.id == event_id }
|
156
118
|
event =
|
157
|
-
if maybe_event
|
119
|
+
if !maybe_event.nil?
|
158
120
|
maybe_event
|
159
121
|
else
|
160
122
|
e = Event.new(id: event_id)
|
@@ -167,12 +129,25 @@ module Laboratory
|
|
167
129
|
|
168
130
|
Laboratory::Config.on_event_recorded&.call(self, variant, user, event)
|
169
131
|
|
170
|
-
|
132
|
+
save
|
171
133
|
event_recording
|
172
134
|
end
|
173
135
|
|
174
|
-
def
|
136
|
+
def analysis_summary_for(event_id)
|
137
|
+
Experiment::AnalysisSummary.new(self, event_id)
|
138
|
+
end
|
139
|
+
|
140
|
+
def save
|
175
141
|
raise errors.first unless valid?
|
142
|
+
unless changeset.empty?
|
143
|
+
changelog_item = Laboratory::Experiment::ChangelogItem.new(
|
144
|
+
changes: changeset,
|
145
|
+
timestamp: Time.now,
|
146
|
+
actor: Laboratory::Config.actor
|
147
|
+
)
|
148
|
+
|
149
|
+
@changelog << changelog_item
|
150
|
+
end
|
176
151
|
Laboratory.config.adapter.write(self)
|
177
152
|
end
|
178
153
|
|
@@ -188,6 +163,24 @@ module Laboratory
|
|
188
163
|
!id.nil? && !algorithm.nil? && valid_variants && valid_percentage_amounts
|
189
164
|
end
|
190
165
|
|
166
|
+
private
|
167
|
+
|
168
|
+
def changeset
|
169
|
+
set = {}
|
170
|
+
set[:id] = [_original_id, id] if _original_id != id
|
171
|
+
set[:algorithm] = [_original_algorithm, algorithm] if _original_algorithm != algorithm
|
172
|
+
|
173
|
+
variants_changeset = variants.map { |variant|
|
174
|
+
{ variant.id => variant.changeset }
|
175
|
+
}
|
176
|
+
variants_changeset.reject! do |change|
|
177
|
+
change.values.all?(&:empty?)
|
178
|
+
end
|
179
|
+
|
180
|
+
set[:variants] = variants_changeset unless variants_changeset.empty?
|
181
|
+
set
|
182
|
+
end
|
183
|
+
|
191
184
|
def errors
|
192
185
|
errors = []
|
193
186
|
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Laboratory
|
2
|
+
module UIHelpers
|
3
|
+
def url(*path_parts)
|
4
|
+
[ path_prefix, path_parts ].join("/").squeeze('/')
|
5
|
+
end
|
6
|
+
|
7
|
+
def path_prefix
|
8
|
+
request.env['SCRIPT_NAME']
|
9
|
+
end
|
10
|
+
|
11
|
+
def experiment_url(experiment)
|
12
|
+
url('experiments', experiment.id, 'edit')
|
13
|
+
end
|
14
|
+
|
15
|
+
def update_percentages_url(experiment)
|
16
|
+
url('experiments', experiment.id, 'update_percentages')
|
17
|
+
end
|
18
|
+
|
19
|
+
def assign_users_to_variant_url(experiment)
|
20
|
+
url('experiments', experiment.id, 'assign_users')
|
21
|
+
end
|
22
|
+
|
23
|
+
def reset_experiment_url(experiment)
|
24
|
+
url('experiments', experiment.id, 'reset')
|
25
|
+
end
|
26
|
+
|
27
|
+
def analysis_summary(experiment, event_id)
|
28
|
+
analysis = experiment.analysis_summary_for(event_id)
|
29
|
+
return if analysis.highest_performing_variant == analysis.lowest_performing_variant
|
30
|
+
|
31
|
+
"#{analysis.highest_performing_variant.id} is performing #{analysis.performance_delta_between_highest_and_lowest * 100}% better than #{analysis.lowest_performing_variant.id}. I'm #{analysis.confidence_level_in_performance_delta * 100}% certain of this."
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
html, body, div, span, applet, object, iframe,
|
2
|
+
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
3
|
+
a, abbr, acronym, address, big, cite, code,
|
4
|
+
del, dfn, em, font, img, ins, kbd, q, s, samp,
|
5
|
+
small, strike, strong, sub, sup, tt, var,
|
6
|
+
dl, dt, dd, ul, li,
|
7
|
+
form, label, legend,
|
8
|
+
table, caption, tbody, tfoot, thead, tr, th, td {
|
9
|
+
margin: 0;
|
10
|
+
padding: 0;
|
11
|
+
border: 0;
|
12
|
+
outline: 0;
|
13
|
+
font-weight: inherit;
|
14
|
+
font-style: normal;
|
15
|
+
font-size: 100%;
|
16
|
+
font-family: inherit;
|
17
|
+
}
|
18
|
+
|
19
|
+
:focus {
|
20
|
+
outline: 0;
|
21
|
+
}
|
22
|
+
|
23
|
+
body {
|
24
|
+
line-height: 1;
|
25
|
+
}
|
26
|
+
|
27
|
+
ul {
|
28
|
+
list-style: none;
|
29
|
+
}
|
30
|
+
|
31
|
+
table {
|
32
|
+
border-collapse: collapse;
|
33
|
+
border-spacing: 0;
|
34
|
+
}
|
35
|
+
|
36
|
+
caption, th, td {
|
37
|
+
text-align: left;
|
38
|
+
font-weight: normal;
|
39
|
+
}
|
40
|
+
|
41
|
+
blockquote:before, blockquote:after,
|
42
|
+
q:before, q:after {
|
43
|
+
content: "";
|
44
|
+
}
|
45
|
+
|
46
|
+
blockquote, q {
|
47
|
+
quotes: "" "";
|
48
|
+
}
|
@@ -0,0 +1,102 @@
|
|
1
|
+
html, body {
|
2
|
+
height: 100%;
|
3
|
+
margin: 0;
|
4
|
+
}
|
5
|
+
|
6
|
+
body {
|
7
|
+
font-family: 'Verdana';
|
8
|
+
background-color: #EEEEEE;
|
9
|
+
color: #383B66;
|
10
|
+
line-height: 2rem;
|
11
|
+
}
|
12
|
+
|
13
|
+
h1 {
|
14
|
+
font-weight: bold;
|
15
|
+
font-size: 4rem;
|
16
|
+
}
|
17
|
+
|
18
|
+
h3 {
|
19
|
+
font-weight: bold;
|
20
|
+
font-size: 2rem;
|
21
|
+
}
|
22
|
+
|
23
|
+
h5 {
|
24
|
+
font-weight: bold;
|
25
|
+
font-size: 1rem;
|
26
|
+
}
|
27
|
+
|
28
|
+
.main {
|
29
|
+
min-height: 100%;
|
30
|
+
margin: 0 auto -30px;
|
31
|
+
}
|
32
|
+
|
33
|
+
.content {
|
34
|
+
padding: 40px;
|
35
|
+
}
|
36
|
+
|
37
|
+
.footer {
|
38
|
+
height: 30px;
|
39
|
+
padding-left: 40px;
|
40
|
+
}
|
41
|
+
|
42
|
+
.header {
|
43
|
+
margin-bottom: 2rem;
|
44
|
+
}
|
45
|
+
|
46
|
+
.experiment {
|
47
|
+
margin-bottom: 2rem;
|
48
|
+
padding: 1rem;
|
49
|
+
border: 0.1rem dashed #DDDDDD;
|
50
|
+
border-radius: 1rem;
|
51
|
+
}
|
52
|
+
|
53
|
+
.experiment-title {
|
54
|
+
}
|
55
|
+
|
56
|
+
.experiment-sections {
|
57
|
+
margin-top: 1rem;
|
58
|
+
}
|
59
|
+
|
60
|
+
.experiment-section:not(:last-child) {
|
61
|
+
margin-bottom: 1rem;
|
62
|
+
}
|
63
|
+
|
64
|
+
.experiment-section-title {
|
65
|
+
margin-bottom: 1rem;
|
66
|
+
}
|
67
|
+
|
68
|
+
.experiment-table {
|
69
|
+
display: flex;
|
70
|
+
flex-direction: column;
|
71
|
+
}
|
72
|
+
|
73
|
+
.experiment .row {
|
74
|
+
flex: 1;
|
75
|
+
display: flex;
|
76
|
+
flex-direction: row;
|
77
|
+
margin-bottom: 0.5rem;
|
78
|
+
}
|
79
|
+
|
80
|
+
.experiment .row.header {
|
81
|
+
padding-bottom: 0.3rem;
|
82
|
+
border-bottom: 0.05rem solid #CCCCCC
|
83
|
+
}
|
84
|
+
|
85
|
+
.experiment .row .column {
|
86
|
+
flex: 1;
|
87
|
+
}
|
88
|
+
|
89
|
+
.experiment .row .column:not(:last-child) {
|
90
|
+
flex-basis: 15rem;
|
91
|
+
flex-grow: 0;
|
92
|
+
}
|
93
|
+
|
94
|
+
.user-id-input-form {
|
95
|
+
|
96
|
+
}
|
97
|
+
|
98
|
+
.user-id-input-form label {
|
99
|
+
display: block
|
100
|
+
}
|
101
|
+
|
102
|
+
|
@@ -0,0 +1,86 @@
|
|
1
|
+
<% all_experiment_event_ids = experiment.variants.flat_map(&:events).map(&:id).uniq %>
|
2
|
+
|
3
|
+
<div class="experiment">
|
4
|
+
<h3 class='experiment-title'>Experiment: <%= experiment.id %></h3>
|
5
|
+
<div class='experiment-sections'>
|
6
|
+
<div class='experiment-section'>
|
7
|
+
<h5 class='experiment-section-title'>Summary</h5>
|
8
|
+
<div class='experiment-table'>
|
9
|
+
<div class="row header">
|
10
|
+
<div class='column'>
|
11
|
+
Variant
|
12
|
+
</div>
|
13
|
+
<div class='column'>
|
14
|
+
Percentage
|
15
|
+
</div>
|
16
|
+
<div class='column'>
|
17
|
+
Number of participants
|
18
|
+
</div>
|
19
|
+
</div>
|
20
|
+
<% experiment.variants.each do |variant| %>
|
21
|
+
<div class="row">
|
22
|
+
<div class='column'>
|
23
|
+
<%= variant.id %>
|
24
|
+
</div>
|
25
|
+
<div class='column'>
|
26
|
+
<%= variant.percentage %>%
|
27
|
+
</div>
|
28
|
+
<div class='column'>
|
29
|
+
<%= variant.participant_ids.count %>
|
30
|
+
</div>
|
31
|
+
</div>
|
32
|
+
<% end %>
|
33
|
+
</div>
|
34
|
+
</div>
|
35
|
+
|
36
|
+
<% all_experiment_event_ids.each do |event_id| %>
|
37
|
+
<div class='experiment-section'>
|
38
|
+
<h5 class='experiment-section-title'>Event: <%= event_id %></h5>
|
39
|
+
<p><%= analysis_summary(experiment, event_id) %>
|
40
|
+
<div class='experiment-table'>
|
41
|
+
<div class="row header">
|
42
|
+
<div class='column'>
|
43
|
+
Variant
|
44
|
+
</div>
|
45
|
+
<div class='column'>
|
46
|
+
Number of participants
|
47
|
+
</div>
|
48
|
+
<div class="column">
|
49
|
+
Number of event instances
|
50
|
+
</div>
|
51
|
+
<div class='column'>
|
52
|
+
Conversion Rate
|
53
|
+
</div>
|
54
|
+
</div>
|
55
|
+
<% experiment.variants.each do |variant| %>
|
56
|
+
<div class="row">
|
57
|
+
<div class='column'>
|
58
|
+
<%= variant.id %>
|
59
|
+
</div>
|
60
|
+
<div class='column'>
|
61
|
+
<%= variant.participant_ids.count %>
|
62
|
+
</div>
|
63
|
+
<% if variant.events.map(&:id).include?(event_id) %>
|
64
|
+
<% event = variant.events.find { |event| event.id == event_id } %>
|
65
|
+
<div class="column">
|
66
|
+
<%= event.event_recordings.count %>
|
67
|
+
</div>
|
68
|
+
<div class="column">
|
69
|
+
<%= ((event.event_recordings.count.fdiv(variant.participant_ids.count)) * 100).round(2) %>%
|
70
|
+
</div>
|
71
|
+
<% else %>
|
72
|
+
<div class='column'>
|
73
|
+
0
|
74
|
+
</div>
|
75
|
+
<div class='column'>
|
76
|
+
N/A
|
77
|
+
</div>
|
78
|
+
<% end %>
|
79
|
+
</div>
|
80
|
+
<% end %>
|
81
|
+
</div>
|
82
|
+
</div>
|
83
|
+
<% end %>
|
84
|
+
</div>
|
85
|
+
<a href="<%= experiment_url(experiment) %>">Edit Experiment</a>
|
86
|
+
</div>
|
@@ -0,0 +1,61 @@
|
|
1
|
+
<div class="experiment">
|
2
|
+
<h3 class='experiment-title'>Experiment: <%= @experiment.id %></h3>
|
3
|
+
<div class='experiment-sections'>
|
4
|
+
<div class='experiment-section'>
|
5
|
+
<h5 class='experiment-section-title'>Proportions</h5>
|
6
|
+
<div class='experiment-table'>
|
7
|
+
<div class="row header">
|
8
|
+
<div class='column'>
|
9
|
+
Variant
|
10
|
+
</div>
|
11
|
+
<div class='column'>
|
12
|
+
Percentage
|
13
|
+
</div>
|
14
|
+
</div>
|
15
|
+
<form method='post' action="<%= update_percentages_url(@experiment) %>">
|
16
|
+
<% @experiment.variants.each do |variant| %>
|
17
|
+
<div class="row">
|
18
|
+
<div class='column'>
|
19
|
+
<%= variant.id %>
|
20
|
+
</div>
|
21
|
+
<div class='column'>
|
22
|
+
<input name="variants[<%= variant.id %>]" type="number" placeholder="percentage" value="<%= variant.percentage %>" />
|
23
|
+
</div>
|
24
|
+
</div>
|
25
|
+
<% end %>
|
26
|
+
<input type="submit" value='Save' />
|
27
|
+
</form>
|
28
|
+
</div>
|
29
|
+
</div>
|
30
|
+
|
31
|
+
<div class='experiment-section'>
|
32
|
+
<h5 class='experiment-section-title'>Assign users to variant</h5>
|
33
|
+
<form class='user-id-input-form' method='post' action="<%= assign_users_to_variant_url(@experiment) %>">
|
34
|
+
<div class='input-group'>
|
35
|
+
<label for='variant'>Variant:</label>
|
36
|
+
<select name='variant_id' id='variant'>
|
37
|
+
<% @experiment.variants.each do |variant| %>
|
38
|
+
<option value="<%= variant.id %>"><%= variant.id %></option>
|
39
|
+
<% end %>
|
40
|
+
</select>
|
41
|
+
</div>
|
42
|
+
<div class='input-group'>
|
43
|
+
<label for='user_ids'>User IDs:</label>
|
44
|
+
<textarea name='user_ids' class='user_ids' placeholder="one user id per line" cols='50' rows='10'></textarea>
|
45
|
+
</div>
|
46
|
+
<div class='input-group'>
|
47
|
+
<input type="submit" value='Assign users' />
|
48
|
+
</div>
|
49
|
+
</form>
|
50
|
+
</div>
|
51
|
+
|
52
|
+
<div class='experiment-section'>
|
53
|
+
<h5 class='experiment-section-title'>Reset experiment</h5>
|
54
|
+
<form class='user-id-input-form' method='post' action="<%= reset_experiment_url(@experiment) %>">
|
55
|
+
<div class='input-group'>
|
56
|
+
<input type="submit" value='Reset Experiment' onclick="return confirm('Are you sure you want to reset this experiment?');" />
|
57
|
+
</div>
|
58
|
+
</form>
|
59
|
+
</div>
|
60
|
+
</div>
|
61
|
+
</div>
|
@@ -0,0 +1,8 @@
|
|
1
|
+
<% if @experiments.any? %>
|
2
|
+
<% @experiments.each do |experiment| %>
|
3
|
+
<%= erb :_experiment, locals: { experiment: experiment } %>
|
4
|
+
<% end %>
|
5
|
+
<% else %>
|
6
|
+
<p class="intro">No experiments have started yet, you need to define them in your code and introduce them to your users.</p>
|
7
|
+
<p class="intro">Check out the <a href='https://github.com/butternutbox/laboratory#readme'>Readme</a> for more help getting started.</p>
|
8
|
+
<% end %>
|
@@ -0,0 +1,24 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta content='text/html; charset=utf-8' http-equiv='Content-Type'>
|
5
|
+
<link href="<%= url 'reset.css' %>" media="screen" rel="stylesheet" type="text/css">
|
6
|
+
<link href="<%= url 'style.css' %>" media="screen" rel="stylesheet" type="text/css">
|
7
|
+
<title>Laboratory</title>
|
8
|
+
|
9
|
+
</head>
|
10
|
+
<body>
|
11
|
+
<div class="main">
|
12
|
+
<div class='content'>
|
13
|
+
<div class="header">
|
14
|
+
<h1>Laboratory 🧪</h1>
|
15
|
+
</div>
|
16
|
+
<%= yield %>
|
17
|
+
</div>
|
18
|
+
</div>
|
19
|
+
|
20
|
+
<div class="footer">
|
21
|
+
<p>Powered by <a href="https://github.com/butternutbox/laboratory">Laboratory</a> 🧪. Handmade with love by your friends at <a href="https://github.com/butternutbox">Butternut Box</a> ❤️</p>
|
22
|
+
</div>
|
23
|
+
</body>
|
24
|
+
</html>
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'sinatra/base'
|
2
|
+
require 'laboratory'
|
3
|
+
require 'laboratory/ui/helpers'
|
4
|
+
|
5
|
+
module Laboratory
|
6
|
+
class UI < Sinatra::Base
|
7
|
+
dir = File.dirname(File.expand_path(__FILE__))
|
8
|
+
|
9
|
+
set :views, "#{dir}/ui/views"
|
10
|
+
set :public_folder, "#{dir}/ui/public"
|
11
|
+
set :static, true
|
12
|
+
set :method_override, true
|
13
|
+
|
14
|
+
helpers Laboratory::UIHelpers
|
15
|
+
|
16
|
+
get '/' do
|
17
|
+
@experiments = Laboratory::Experiment.all
|
18
|
+
erb :index
|
19
|
+
end
|
20
|
+
|
21
|
+
get '/experiments/:id/edit' do
|
22
|
+
@experiment = Laboratory::Experiment.find(params[:id])
|
23
|
+
erb :edit
|
24
|
+
end
|
25
|
+
|
26
|
+
# params = {variants: { control => 40, variant_a => 60 }}
|
27
|
+
post '/experiments/:id/update_percentages' do
|
28
|
+
experiment = Laboratory::Experiment.find(params[:id])
|
29
|
+
|
30
|
+
params[:variants].each do |variant_id, percentage|
|
31
|
+
variant = experiment.variants.find { |v| v.id == variant_id }
|
32
|
+
variant.percentage = percentage.to_i
|
33
|
+
end
|
34
|
+
|
35
|
+
experiment.save
|
36
|
+
redirect experiment_url(experiment)
|
37
|
+
end
|
38
|
+
|
39
|
+
# params = {variant_id: 'control', user_ids: []}
|
40
|
+
post '/experiments/:id/assign_users' do
|
41
|
+
experiment = Laboratory::Experiment.find(params[:id])
|
42
|
+
variant = experiment.variants.find { |v| v.id == params[:variant_id] }
|
43
|
+
user_ids = params[:user_ids].split("\r\n")
|
44
|
+
|
45
|
+
user_ids.each do |user_id|
|
46
|
+
user = Laboratory::User.new(id: user_id)
|
47
|
+
experiment.assign_to_variant(variant.id, user: user)
|
48
|
+
end
|
49
|
+
|
50
|
+
redirect experiment_url(experiment)
|
51
|
+
end
|
52
|
+
|
53
|
+
post '/experiments/:id/reset' do
|
54
|
+
experiment = Laboratory::Experiment.find(params[:id])
|
55
|
+
experiment.reset
|
56
|
+
redirect experiment_url(experiment)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/laboratory/version.rb
CHANGED
data/lib/laboratory.rb
CHANGED
@@ -10,6 +10,9 @@ require 'laboratory/experiment/variant'
|
|
10
10
|
require 'laboratory/experiment/changelog_item'
|
11
11
|
require 'laboratory/experiment/event'
|
12
12
|
require 'laboratory/experiment/event/recording'
|
13
|
+
require 'laboratory/experiment/analysis_summary'
|
14
|
+
require 'laboratory/calculations/z_score'
|
15
|
+
require 'laboratory/calculations/confidence_level'
|
13
16
|
|
14
17
|
module Laboratory
|
15
18
|
class << self
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: laboratory
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Niall Paterson
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-03-
|
11
|
+
date: 2020-03-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|
@@ -24,6 +24,34 @@ dependencies:
|
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '2.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: sinatra
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.2.6
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 1.2.6
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rack-test
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.1'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.1'
|
27
55
|
- !ruby/object:Gem::Dependency
|
28
56
|
name: rspec
|
29
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -76,12 +104,23 @@ files:
|
|
76
104
|
- lib/laboratory/adapters/redis_adapter.rb
|
77
105
|
- lib/laboratory/algorithms.rb
|
78
106
|
- lib/laboratory/algorithms/random.rb
|
107
|
+
- lib/laboratory/calculations/confidence_level.rb
|
108
|
+
- lib/laboratory/calculations/z_score.rb
|
79
109
|
- lib/laboratory/config.rb
|
80
110
|
- lib/laboratory/experiment.rb
|
111
|
+
- lib/laboratory/experiment/analysis_summary.rb
|
81
112
|
- lib/laboratory/experiment/changelog_item.rb
|
82
113
|
- lib/laboratory/experiment/event.rb
|
83
114
|
- lib/laboratory/experiment/event/recording.rb
|
84
115
|
- lib/laboratory/experiment/variant.rb
|
116
|
+
- lib/laboratory/ui.rb
|
117
|
+
- lib/laboratory/ui/helpers.rb
|
118
|
+
- lib/laboratory/ui/public/reset.css
|
119
|
+
- lib/laboratory/ui/public/style.css
|
120
|
+
- lib/laboratory/ui/views/_experiment.erb
|
121
|
+
- lib/laboratory/ui/views/edit.erb
|
122
|
+
- lib/laboratory/ui/views/index.erb
|
123
|
+
- lib/laboratory/ui/views/layout.erb
|
85
124
|
- lib/laboratory/user.rb
|
86
125
|
- lib/laboratory/version.rb
|
87
126
|
homepage: https://github.com/butternutbox/laboratory
|