ab-split 1.0.0

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.
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