ab-split 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +30 -0
  3. data/.csslintrc +2 -0
  4. data/.eslintignore +1 -0
  5. data/.eslintrc +213 -0
  6. data/.github/FUNDING.yml +1 -0
  7. data/.github/ISSUE_TEMPLATE/bug_report.md +24 -0
  8. data/.rspec +1 -0
  9. data/.rubocop.yml +7 -0
  10. data/.rubocop_todo.yml +679 -0
  11. data/.travis.yml +60 -0
  12. data/Appraisals +19 -0
  13. data/CHANGELOG.md +696 -0
  14. data/CODE_OF_CONDUCT.md +74 -0
  15. data/CONTRIBUTING.md +62 -0
  16. data/Gemfile +7 -0
  17. data/LICENSE +22 -0
  18. data/README.md +955 -0
  19. data/Rakefile +9 -0
  20. data/ab-split.gemspec +44 -0
  21. data/gemfiles/4.2.gemfile +9 -0
  22. data/gemfiles/5.0.gemfile +9 -0
  23. data/gemfiles/5.1.gemfile +9 -0
  24. data/gemfiles/5.2.gemfile +9 -0
  25. data/gemfiles/6.0.gemfile +9 -0
  26. data/lib/split.rb +76 -0
  27. data/lib/split/algorithms/block_randomization.rb +23 -0
  28. data/lib/split/algorithms/weighted_sample.rb +18 -0
  29. data/lib/split/algorithms/whiplash.rb +38 -0
  30. data/lib/split/alternative.rb +191 -0
  31. data/lib/split/combined_experiments_helper.rb +37 -0
  32. data/lib/split/configuration.rb +255 -0
  33. data/lib/split/dashboard.rb +74 -0
  34. data/lib/split/dashboard/helpers.rb +45 -0
  35. data/lib/split/dashboard/pagination_helpers.rb +86 -0
  36. data/lib/split/dashboard/paginator.rb +16 -0
  37. data/lib/split/dashboard/public/dashboard-filtering.js +43 -0
  38. data/lib/split/dashboard/public/dashboard.js +24 -0
  39. data/lib/split/dashboard/public/jquery-1.11.1.min.js +4 -0
  40. data/lib/split/dashboard/public/reset.css +48 -0
  41. data/lib/split/dashboard/public/style.css +328 -0
  42. data/lib/split/dashboard/views/_controls.erb +18 -0
  43. data/lib/split/dashboard/views/_experiment.erb +155 -0
  44. data/lib/split/dashboard/views/_experiment_with_goal_header.erb +8 -0
  45. data/lib/split/dashboard/views/index.erb +26 -0
  46. data/lib/split/dashboard/views/layout.erb +27 -0
  47. data/lib/split/encapsulated_helper.rb +42 -0
  48. data/lib/split/engine.rb +15 -0
  49. data/lib/split/exceptions.rb +6 -0
  50. data/lib/split/experiment.rb +486 -0
  51. data/lib/split/experiment_catalog.rb +51 -0
  52. data/lib/split/extensions/string.rb +16 -0
  53. data/lib/split/goals_collection.rb +45 -0
  54. data/lib/split/helper.rb +165 -0
  55. data/lib/split/metric.rb +101 -0
  56. data/lib/split/persistence.rb +28 -0
  57. data/lib/split/persistence/cookie_adapter.rb +94 -0
  58. data/lib/split/persistence/dual_adapter.rb +85 -0
  59. data/lib/split/persistence/redis_adapter.rb +57 -0
  60. data/lib/split/persistence/session_adapter.rb +29 -0
  61. data/lib/split/redis_interface.rb +50 -0
  62. data/lib/split/trial.rb +117 -0
  63. data/lib/split/user.rb +69 -0
  64. data/lib/split/version.rb +7 -0
  65. data/lib/split/zscore.rb +57 -0
  66. data/spec/algorithms/block_randomization_spec.rb +32 -0
  67. data/spec/algorithms/weighted_sample_spec.rb +19 -0
  68. data/spec/algorithms/whiplash_spec.rb +24 -0
  69. data/spec/alternative_spec.rb +320 -0
  70. data/spec/combined_experiments_helper_spec.rb +57 -0
  71. data/spec/configuration_spec.rb +258 -0
  72. data/spec/dashboard/pagination_helpers_spec.rb +200 -0
  73. data/spec/dashboard/paginator_spec.rb +37 -0
  74. data/spec/dashboard_helpers_spec.rb +42 -0
  75. data/spec/dashboard_spec.rb +210 -0
  76. data/spec/encapsulated_helper_spec.rb +52 -0
  77. data/spec/experiment_catalog_spec.rb +53 -0
  78. data/spec/experiment_spec.rb +533 -0
  79. data/spec/goals_collection_spec.rb +80 -0
  80. data/spec/helper_spec.rb +1111 -0
  81. data/spec/metric_spec.rb +31 -0
  82. data/spec/persistence/cookie_adapter_spec.rb +106 -0
  83. data/spec/persistence/dual_adapter_spec.rb +194 -0
  84. data/spec/persistence/redis_adapter_spec.rb +90 -0
  85. data/spec/persistence/session_adapter_spec.rb +32 -0
  86. data/spec/persistence_spec.rb +34 -0
  87. data/spec/redis_interface_spec.rb +111 -0
  88. data/spec/spec_helper.rb +52 -0
  89. data/spec/split_spec.rb +43 -0
  90. data/spec/support/cookies_mock.rb +20 -0
  91. data/spec/trial_spec.rb +299 -0
  92. data/spec/user_spec.rb +87 -0
  93. metadata +322 -0
@@ -0,0 +1,32 @@
1
+ require "spec_helper"
2
+
3
+ describe Split::Algorithms::BlockRandomization do
4
+
5
+ let(:experiment) { Split::Experiment.new 'experiment' }
6
+ let(:alternative_A) { Split::Alternative.new 'A', 'experiment' }
7
+ let(:alternative_B) { Split::Alternative.new 'B', 'experiment' }
8
+ let(:alternative_C) { Split::Alternative.new 'C', 'experiment' }
9
+
10
+ before :each do
11
+ allow(experiment).to receive(:alternatives) { [alternative_A, alternative_B, alternative_C] }
12
+ end
13
+
14
+ it "should return an alternative" do
15
+ expect(Split::Algorithms::BlockRandomization.choose_alternative(experiment).class).to eq(Split::Alternative)
16
+ end
17
+
18
+ it "should always return the minimum participation option" do
19
+ allow(alternative_A).to receive(:participant_count) { 1 }
20
+ allow(alternative_B).to receive(:participant_count) { 1 }
21
+ allow(alternative_C).to receive(:participant_count) { 0 }
22
+ expect(Split::Algorithms::BlockRandomization.choose_alternative(experiment)).to eq(alternative_C)
23
+ end
24
+
25
+ it "should return one of the minimum participation options when multiple" do
26
+ allow(alternative_A).to receive(:participant_count) { 0 }
27
+ allow(alternative_B).to receive(:participant_count) { 0 }
28
+ allow(alternative_C).to receive(:participant_count) { 0 }
29
+ alternative = Split::Algorithms::BlockRandomization.choose_alternative(experiment)
30
+ expect([alternative_A, alternative_B, alternative_C].include?(alternative)).to be(true)
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ describe Split::Algorithms::WeightedSample do
5
+ it "should return an alternative" do
6
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 100}, {'red' => 0 })
7
+ expect(Split::Algorithms::WeightedSample.choose_alternative(experiment).class).to eq(Split::Alternative)
8
+ end
9
+
10
+ it "should always return a heavily weighted option" do
11
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 100}, {'red' => 0 })
12
+ expect(Split::Algorithms::WeightedSample.choose_alternative(experiment).name).to eq('blue')
13
+ end
14
+
15
+ it "should return one of the results" do
16
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
17
+ expect(['red', 'blue']).to include Split::Algorithms::WeightedSample.choose_alternative(experiment).name
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ describe Split::Algorithms::Whiplash do
5
+
6
+ it "should return an algorithm" do
7
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
8
+ expect(Split::Algorithms::Whiplash.choose_alternative(experiment).class).to eq(Split::Alternative)
9
+ end
10
+
11
+ it "should return one of the results" do
12
+ experiment = Split::ExperimentCatalog.find_or_create('link_color', {'blue' => 1}, {'red' => 1 })
13
+ expect(['red', 'blue']).to include Split::Algorithms::Whiplash.choose_alternative(experiment).name
14
+ end
15
+
16
+ it "should guess floats" do
17
+ expect(Split::Algorithms::Whiplash.send(:arm_guess, 0, 0).class).to eq(Float)
18
+ expect(Split::Algorithms::Whiplash.send(:arm_guess, 1, 0).class).to eq(Float)
19
+ expect(Split::Algorithms::Whiplash.send(:arm_guess, 2, 1).class).to eq(Float)
20
+ expect(Split::Algorithms::Whiplash.send(:arm_guess, 1000, 5).class).to eq(Float)
21
+ expect(Split::Algorithms::Whiplash.send(:arm_guess, 10, -2).class).to eq(Float)
22
+ end
23
+
24
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+ require 'split/alternative'
4
+
5
+ describe Split::Alternative do
6
+
7
+ let(:alternative) {
8
+ Split::Alternative.new('Basket', 'basket_text')
9
+ }
10
+
11
+ let(:alternative2) {
12
+ Split::Alternative.new('Cart', 'basket_text')
13
+ }
14
+
15
+ let!(:experiment) {
16
+ Split::ExperimentCatalog.find_or_create({"basket_text" => ["purchase", "refund"]}, "Basket", "Cart")
17
+ }
18
+
19
+ let(:goal1) { "purchase" }
20
+ let(:goal2) { "refund" }
21
+
22
+ it "should have goals" do
23
+ expect(alternative.goals).to eq(["purchase", "refund"])
24
+ end
25
+
26
+ it "should have and only return the name" do
27
+ expect(alternative.name).to eq('Basket')
28
+ end
29
+
30
+ describe 'weights' do
31
+ it "should set the weights" do
32
+ experiment = Split::Experiment.new('basket_text', :alternatives => [{'Basket' => 0.6}, {"Cart" => 0.4}])
33
+ first = experiment.alternatives[0]
34
+ expect(first.name).to eq('Basket')
35
+ expect(first.weight).to eq(0.6)
36
+
37
+ second = experiment.alternatives[1]
38
+ expect(second.name).to eq('Cart')
39
+ expect(second.weight).to eq(0.4)
40
+ end
41
+
42
+ it "accepts probability on alternatives" do
43
+ Split.configuration.experiments = {
44
+ :my_experiment => {
45
+ :alternatives => [
46
+ { :name => "control_opt", :percent => 67 },
47
+ { :name => "second_opt", :percent => 10 },
48
+ { :name => "third_opt", :percent => 23 },
49
+ ]
50
+ }
51
+ }
52
+ experiment = Split::Experiment.new(:my_experiment)
53
+ first = experiment.alternatives[0]
54
+ expect(first.name).to eq('control_opt')
55
+ expect(first.weight).to eq(0.67)
56
+
57
+ second = experiment.alternatives[1]
58
+ expect(second.name).to eq('second_opt')
59
+ expect(second.weight).to eq(0.1)
60
+ end
61
+
62
+ it "accepts probability on some alternatives" do
63
+ Split.configuration.experiments = {
64
+ :my_experiment => {
65
+ :alternatives => [
66
+ { :name => "control_opt", :percent => 34 },
67
+ "second_opt",
68
+ { :name => "third_opt", :percent => 23 },
69
+ "fourth_opt",
70
+ ],
71
+ }
72
+ }
73
+ experiment = Split::Experiment.new(:my_experiment)
74
+ alts = experiment.alternatives
75
+ [
76
+ ["control_opt", 0.34],
77
+ ["second_opt", 0.215],
78
+ ["third_opt", 0.23],
79
+ ["fourth_opt", 0.215]
80
+ ].each do |h|
81
+ name, weight = h
82
+ alt = alts.shift
83
+ expect(alt.name).to eq(name)
84
+ expect(alt.weight).to eq(weight)
85
+ end
86
+ end
87
+ #
88
+ it "allows name param without probability" do
89
+ Split.configuration.experiments = {
90
+ :my_experiment => {
91
+ :alternatives => [
92
+ { :name => "control_opt" },
93
+ "second_opt",
94
+ { :name => "third_opt", :percent => 64 },
95
+ ],
96
+ }
97
+ }
98
+ experiment = Split::Experiment.new(:my_experiment)
99
+ alts = experiment.alternatives
100
+ [
101
+ ["control_opt", 0.18],
102
+ ["second_opt", 0.18],
103
+ ["third_opt", 0.64],
104
+ ].each do |h|
105
+ name, weight = h
106
+ alt = alts.shift
107
+ expect(alt.name).to eq(name)
108
+ expect(alt.weight).to eq(weight)
109
+ end
110
+ end
111
+ end
112
+
113
+ it "should have a default participation count of 0" do
114
+ expect(alternative.participant_count).to eq(0)
115
+ end
116
+
117
+ it "should have a default completed count of 0 for each goal" do
118
+ expect(alternative.completed_count).to eq(0)
119
+ expect(alternative.completed_count(goal1)).to eq(0)
120
+ expect(alternative.completed_count(goal2)).to eq(0)
121
+ end
122
+
123
+ it "should belong to an experiment" do
124
+ expect(alternative.experiment.name).to eq(experiment.name)
125
+ end
126
+
127
+ it "should save to redis" do
128
+ alternative.save
129
+ expect(Split.redis.exists('basket_text:Basket')).to be true
130
+ end
131
+
132
+ it "should increment participation count" do
133
+ old_participant_count = alternative.participant_count
134
+ alternative.increment_participation
135
+ expect(alternative.participant_count).to eq(old_participant_count+1)
136
+ end
137
+
138
+ it "should increment completed count for each goal" do
139
+ old_default_completed_count = alternative.completed_count
140
+ old_completed_count_for_goal1 = alternative.completed_count(goal1)
141
+ old_completed_count_for_goal2 = alternative.completed_count(goal2)
142
+
143
+ alternative.increment_completion
144
+ alternative.increment_completion(goal1)
145
+ alternative.increment_completion(goal2)
146
+
147
+ expect(alternative.completed_count).to eq(old_default_completed_count+1)
148
+ expect(alternative.completed_count(goal1)).to eq(old_completed_count_for_goal1+1)
149
+ expect(alternative.completed_count(goal2)).to eq(old_completed_count_for_goal2+1)
150
+ end
151
+
152
+ it "can be reset" do
153
+ alternative.participant_count = 10
154
+ alternative.set_completed_count(4, goal1)
155
+ alternative.set_completed_count(5, goal2)
156
+ alternative.set_completed_count(6)
157
+ alternative.reset
158
+ expect(alternative.participant_count).to eq(0)
159
+ expect(alternative.completed_count(goal1)).to eq(0)
160
+ expect(alternative.completed_count(goal2)).to eq(0)
161
+ expect(alternative.completed_count).to eq(0)
162
+ end
163
+
164
+ it "should know if it is the control of an experiment" do
165
+ expect(alternative.control?).to be_truthy
166
+ expect(alternative2.control?).to be_falsey
167
+ end
168
+
169
+ describe 'unfinished_count' do
170
+ it "should be difference between participant and completed counts" do
171
+ alternative.increment_participation
172
+ expect(alternative.unfinished_count).to eq(alternative.participant_count)
173
+ end
174
+
175
+ it "should return the correct unfinished_count" do
176
+ alternative.participant_count = 10
177
+ alternative.set_completed_count(4, goal1)
178
+ alternative.set_completed_count(3, goal2)
179
+ alternative.set_completed_count(2)
180
+
181
+ expect(alternative.unfinished_count).to eq(1)
182
+ end
183
+ end
184
+
185
+ describe 'conversion rate' do
186
+ it "should be 0 if there are no conversions" do
187
+ expect(alternative.completed_count).to eq(0)
188
+ expect(alternative.conversion_rate).to eq(0)
189
+ end
190
+
191
+ it "calculate conversion rate" do
192
+ expect(alternative).to receive(:participant_count).exactly(6).times.and_return(10)
193
+ expect(alternative).to receive(:completed_count).and_return(4)
194
+ expect(alternative.conversion_rate).to eq(0.4)
195
+
196
+ expect(alternative).to receive(:completed_count).with(goal1).and_return(5)
197
+ expect(alternative.conversion_rate(goal1)).to eq(0.5)
198
+
199
+ expect(alternative).to receive(:completed_count).with(goal2).and_return(6)
200
+ expect(alternative.conversion_rate(goal2)).to eq(0.6)
201
+ end
202
+ end
203
+
204
+ describe "probability winner" do
205
+ before do
206
+ experiment.calc_winning_alternatives
207
+ end
208
+
209
+ it "should have a probability of being the winning alternative (p_winner)" do
210
+ expect(alternative.p_winner).not_to be_nil
211
+ end
212
+
213
+ it "should have a probability of being the winner for each goal" do
214
+ expect(alternative.p_winner(goal1)).not_to be_nil
215
+ end
216
+
217
+ it "should be possible to set the p_winner" do
218
+ alternative.set_p_winner(0.5)
219
+ expect(alternative.p_winner).to eq(0.5)
220
+ end
221
+
222
+ it "should be possible to set the p_winner for each goal" do
223
+ alternative.set_p_winner(0.5, goal1)
224
+ expect(alternative.p_winner(goal1)).to eq(0.5)
225
+ end
226
+ end
227
+
228
+ describe 'z score' do
229
+
230
+ it "should return an error string when the control has 0 people" do
231
+ expect(alternative2.z_score).to eq("Needs 30+ participants.")
232
+ expect(alternative2.z_score(goal1)).to eq("Needs 30+ participants.")
233
+ expect(alternative2.z_score(goal2)).to eq("Needs 30+ participants.")
234
+ end
235
+
236
+ it "should return an error string when the data is skewed or incomplete as per the np > 5 test" do
237
+ control = experiment.control
238
+ control.participant_count = 100
239
+ control.set_completed_count(50)
240
+
241
+ alternative2.participant_count = 50
242
+ alternative2.set_completed_count(1)
243
+
244
+ expect(alternative2.z_score).to eq("Needs 5+ conversions.")
245
+ end
246
+
247
+ it "should return a float for a z_score given proper data" do
248
+ control = experiment.control
249
+ control.participant_count = 120
250
+ control.set_completed_count(20)
251
+
252
+ alternative2.participant_count = 100
253
+ alternative2.set_completed_count(25)
254
+
255
+ expect(alternative2.z_score).to be_kind_of(Float)
256
+ expect(alternative2.z_score).to_not eq(0)
257
+ end
258
+
259
+ it "should correctly calculate a z_score given proper data" do
260
+ control = experiment.control
261
+ control.participant_count = 126
262
+ control.set_completed_count(89)
263
+
264
+ alternative2.participant_count = 142
265
+ alternative2.set_completed_count(119)
266
+
267
+ expect(alternative2.z_score.round(2)).to eq(2.58)
268
+ end
269
+
270
+ it "should be N/A for the control" do
271
+ control = experiment.control
272
+ expect(control.z_score).to eq('N/A')
273
+ expect(control.z_score(goal1)).to eq('N/A')
274
+ expect(control.z_score(goal2)).to eq('N/A')
275
+ end
276
+
277
+ it "should not blow up for Conversion Rates > 1" do
278
+ control = experiment.control
279
+ control.participant_count = 3474
280
+ control.set_completed_count(4244)
281
+
282
+ alternative2.participant_count = 3434
283
+ alternative2.set_completed_count(4358)
284
+
285
+ expect { control.z_score }.not_to raise_error
286
+ expect { alternative2.z_score }.not_to raise_error
287
+ end
288
+ end
289
+
290
+ describe "extra_info" do
291
+ it "reads saved value of recorded_info in redis" do
292
+ saved_recorded_info = {"key_1" => 1, "key_2" => "2"}
293
+ Split.redis.hset "#{alternative.experiment_name}:#{alternative.name}", 'recorded_info', saved_recorded_info.to_json
294
+ extra_info = alternative.extra_info
295
+
296
+ expect(extra_info).to eql(saved_recorded_info)
297
+ end
298
+ end
299
+
300
+ describe "record_extra_info" do
301
+ it "saves key" do
302
+ alternative.record_extra_info("signup", 1)
303
+ expect(alternative.extra_info["signup"]).to eql(1)
304
+ end
305
+
306
+ it "adds value to saved key's value second argument is number" do
307
+ alternative.record_extra_info("signup", 1)
308
+ alternative.record_extra_info("signup", 2)
309
+ expect(alternative.extra_info["signup"]).to eql(3)
310
+ end
311
+
312
+ it "sets saved's key value to the second argument if it's a string" do
313
+ alternative.record_extra_info("signup", "Value 1")
314
+ expect(alternative.extra_info["signup"]).to eql("Value 1")
315
+
316
+ alternative.record_extra_info("signup", "Value 2")
317
+ expect(alternative.extra_info["signup"]).to eql("Value 2")
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+ require 'split/combined_experiments_helper'
4
+
5
+ describe Split::CombinedExperimentsHelper do
6
+ include Split::CombinedExperimentsHelper
7
+
8
+ describe 'ab_combined_test' do
9
+ let!(:config_enabled) { true }
10
+ let!(:combined_experiments) { [:exp_1_click, :exp_1_scroll ]}
11
+ let!(:allow_multiple_experiments) { true }
12
+
13
+ before do
14
+ Split.configuration.experiments = {
15
+ :combined_exp_1 => {
16
+ :alternatives => [ {"control"=> 0.5}, {"test-alt"=> 0.5} ],
17
+ :metric => :my_metric,
18
+ :combined_experiments => combined_experiments
19
+ }
20
+ }
21
+ Split.configuration.enabled = config_enabled
22
+ Split.configuration.allow_multiple_experiments = allow_multiple_experiments
23
+ end
24
+
25
+ context 'without config enabled' do
26
+ let!(:config_enabled) { false }
27
+
28
+ it "raises an error" do
29
+ expect(lambda { ab_combined_test :combined_exp_1 }).to raise_error(Split::InvalidExperimentsFormatError )
30
+ end
31
+ end
32
+
33
+ context 'multiple experiments disabled' do
34
+ let!(:allow_multiple_experiments) { false }
35
+
36
+ it "raises an error if multiple experiments is disabled" do
37
+ expect(lambda { ab_combined_test :combined_exp_1 }).to raise_error(Split::InvalidExperimentsFormatError)
38
+ end
39
+ end
40
+
41
+ context 'without combined experiments' do
42
+ let!(:combined_experiments) { nil }
43
+
44
+ it "raises an error" do
45
+ expect(lambda { ab_combined_test :combined_exp_1 }).to raise_error(Split::InvalidExperimentsFormatError )
46
+ end
47
+ end
48
+
49
+ it "uses same alternative for all sub experiments and returns the alternative" do
50
+ allow(self).to receive(:get_alternative) { "test-alt" }
51
+ expect(self).to receive(:ab_test).with(:exp_1_click, {"control"=>0.5}, {"test-alt"=>0.5}) { "test-alt" }
52
+ expect(self).to receive(:ab_test).with(:exp_1_scroll, [{"control" => 0, "test-alt" => 1}])
53
+
54
+ expect(ab_combined_test('combined_exp_1')).to eq('test-alt')
55
+ end
56
+ end
57
+ end