laboratory 0.1.0 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|