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
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env rake
2
+ # frozen_string_literal: true
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+ require 'appraisal'
6
+
7
+ RSpec::Core::RakeTask.new('spec')
8
+
9
+ task :default => :spec
data/ab-split.gemspec ADDED
@@ -0,0 +1,44 @@
1
+ # -*- encoding: utf-8 -*-
2
+ # frozen_string_literal: true
3
+ $:.push File.expand_path("../lib", __FILE__)
4
+ require "split/version"
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "ab-split"
8
+ s.version = Split::VERSION
9
+ s.platform = Gem::Platform::RUBY
10
+ s.licenses = ['MIT']
11
+ s.authors = ["aibotyu"]
12
+ s.email = ["284894567@qq.com"]
13
+ s.homepage = "https://github.com/WeiGangqiang/split"
14
+ s.summary = "Rack based split testing framework"
15
+
16
+ s.metadata = {
17
+ "homepage_uri" => "https://github.com/WeiGangqiang/split",
18
+ "changelog_uri" => "https://github.com/WeiGangqiang/split/blob/master/CHANGELOG.md",
19
+ "source_code_uri" => "https://github.com/WeiGangqiang/split",
20
+ "bug_tracker_uri" => "https://github.com/WeiGangqiang/split/issues",
21
+ "wiki_uri" => "https://github.com/WeiGangqiang/split/wiki",
22
+ "mailing_list_uri" => "https://github.com/WeiGangqiang/split/split-ruby"
23
+ }
24
+
25
+ s.required_ruby_version = '>= 1.9.3'
26
+ s.required_rubygems_version = '>= 2.0.0'
27
+
28
+ s.files = `git ls-files`.split("\n")
29
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
30
+ s.require_paths = ["lib"]
31
+
32
+ s.add_dependency 'redis', '>= 2.1'
33
+ s.add_dependency 'sinatra', '>= 1.2.6'
34
+ s.add_dependency 'simple-random', '>= 0.9.3'
35
+
36
+ s.add_development_dependency 'bundler', '>= 1.17'
37
+ s.add_development_dependency 'simplecov', '~> 0.15'
38
+ s.add_development_dependency 'rack-test', '~> 0.6'
39
+ s.add_development_dependency 'rake', '~> 12'
40
+ s.add_development_dependency 'rspec', '~> 3.7'
41
+ s.add_development_dependency 'pry', '~> 0.10'
42
+ s.add_development_dependency 'fakeredis', '~> 0.7'
43
+ s.add_development_dependency 'rails', '>= 4.2'
44
+ end
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "codeclimate-test-reporter"
7
+ gem "rails", "~> 4.2"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "codeclimate-test-reporter"
7
+ gem "rails", "~> 5.0"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "codeclimate-test-reporter"
7
+ gem "rails", "~> 5.1"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "codeclimate-test-reporter"
7
+ gem "rails", "~> 5.2"
8
+
9
+ gemspec path: "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "codeclimate-test-reporter"
7
+ gem "rails", "~> 6.0"
8
+
9
+ gemspec path: "../"
data/lib/split.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+ require 'redis'
3
+
4
+ require 'split/algorithms/block_randomization'
5
+ require 'split/algorithms/weighted_sample'
6
+ require 'split/algorithms/whiplash'
7
+ require 'split/alternative'
8
+ require 'split/configuration'
9
+ require 'split/encapsulated_helper'
10
+ require 'split/exceptions'
11
+ require 'split/experiment'
12
+ require 'split/experiment_catalog'
13
+ require 'split/extensions/string'
14
+ require 'split/goals_collection'
15
+ require 'split/helper'
16
+ require 'split/combined_experiments_helper'
17
+ require 'split/metric'
18
+ require 'split/persistence'
19
+ require 'split/redis_interface'
20
+ require 'split/trial'
21
+ require 'split/user'
22
+ require 'split/version'
23
+ require 'split/zscore'
24
+ require 'split/engine' if defined?(Rails)
25
+
26
+ module Split
27
+ extend self
28
+ attr_accessor :configuration
29
+
30
+ # Accepts:
31
+ # 1. A redis URL (valid for `Redis.new(url: url)`)
32
+ # 2. an options hash compatible with `Redis.new`
33
+ # 3. or a valid Redis instance (one that responds to `#smembers`). Likely,
34
+ # this will be an instance of either `Redis`, `Redis::Client`,
35
+ # `Redis::DistRedis`, or `Redis::Namespace`.
36
+ def redis=(server)
37
+ @redis = if server.is_a?(String)
38
+ Redis.new(:url => server, :thread_safe => true)
39
+ elsif server.is_a?(Hash)
40
+ Redis.new(server.merge(:thread_safe => true))
41
+ elsif server.respond_to?(:smembers)
42
+ server
43
+ else
44
+ raise ArgumentError,
45
+ "You must supply a url, options hash or valid Redis connection instance"
46
+ end
47
+ end
48
+
49
+ # Returns the current Redis connection. If none has been created, will
50
+ # create a new one.
51
+ def redis
52
+ return @redis if @redis
53
+ self.redis = self.configuration.redis
54
+ self.redis
55
+ end
56
+
57
+ # Call this method to modify defaults in your initializers.
58
+ #
59
+ # @example
60
+ # Split.configure do |config|
61
+ # config.ignore_ip_addresses = '192.168.2.1'
62
+ # end
63
+ def configure
64
+ self.configuration ||= Configuration.new
65
+ yield(configuration)
66
+ end
67
+ end
68
+
69
+ # Check to see if being run in a Rails application. If so, wait until before_initialize to run configuration so Gems that create ENV variables have the chance to initialize first.
70
+ if defined?(::Rails)
71
+ class Railtie < Rails::Railtie
72
+ config.before_initialize { Split.configure {} }
73
+ end
74
+ else
75
+ Split.configure {}
76
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ # Selects alternative with minimum count of participants
3
+ # If all counts are even (i.e. all are minimum), samples from all possible alternatives
4
+
5
+ module Split
6
+ module Algorithms
7
+ module BlockRandomization
8
+ class << self
9
+ def choose_alternative(experiment)
10
+ minimum_participant_alternatives(experiment.alternatives).sample
11
+ end
12
+
13
+ private
14
+
15
+ def minimum_participant_alternatives(alternatives)
16
+ alternatives_by_count = alternatives.group_by(&:participant_count)
17
+ min_group = alternatives_by_count.min_by { |k, v| k }
18
+ min_group.last
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ module Algorithms
4
+ module WeightedSample
5
+ def self.choose_alternative(experiment)
6
+ weights = experiment.alternatives.map(&:weight)
7
+
8
+ total = weights.inject(:+)
9
+ point = rand * total
10
+
11
+ experiment.alternatives.zip(weights).each do |n,w|
12
+ return n if w >= point
13
+ point -= w
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+ # A multi-armed bandit implementation inspired by
3
+ # @aaronsw and victorykit/whiplash
4
+ require 'simple-random'
5
+
6
+ module Split
7
+ module Algorithms
8
+ module Whiplash
9
+ class << self
10
+ def choose_alternative(experiment)
11
+ experiment[best_guess(experiment.alternatives)]
12
+ end
13
+
14
+ private
15
+
16
+ def arm_guess(participants, completions)
17
+ a = [participants, 0].max
18
+ b = [participants-completions, 0].max
19
+ s = SimpleRandom.new; s.set_seed; s.beta(a+fairness_constant, b+fairness_constant)
20
+ end
21
+
22
+ def best_guess(alternatives)
23
+ guesses = {}
24
+ alternatives.each do |alternative|
25
+ guesses[alternative.name] = arm_guess(alternative.participant_count, alternative.all_completed_count)
26
+ end
27
+ gmax = guesses.values.max
28
+ best = guesses.keys.select { |name| guesses[name] == gmax }
29
+ best.sample
30
+ end
31
+
32
+ def fairness_constant
33
+ 7
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class Alternative
4
+ attr_accessor :name
5
+ attr_accessor :experiment_name
6
+ attr_accessor :weight
7
+ attr_accessor :recorded_info
8
+
9
+ def initialize(name, experiment_name)
10
+ @experiment_name = experiment_name
11
+ if Hash === name
12
+ @name = name.keys.first
13
+ @weight = name.values.first
14
+ else
15
+ @name = name
16
+ @weight = 1
17
+ end
18
+ @p_winner = 0.0
19
+ end
20
+
21
+ def to_s
22
+ name
23
+ end
24
+
25
+ def goals
26
+ self.experiment.goals
27
+ end
28
+
29
+ def p_winner(goal = nil)
30
+ field = set_prob_field(goal)
31
+ @p_winner = Split.redis.hget(key, field).to_f
32
+ end
33
+
34
+ def set_p_winner(prob, goal = nil)
35
+ field = set_prob_field(goal)
36
+ Split.redis.hset(key, field, prob.to_f)
37
+ end
38
+
39
+ def participant_count
40
+ Split.redis.hget(key, 'participant_count').to_i
41
+ end
42
+
43
+ def participant_count=(count)
44
+ Split.redis.hset(key, 'participant_count', count.to_i)
45
+ end
46
+
47
+ def completed_count(goal = nil)
48
+ field = set_field(goal)
49
+ Split.redis.hget(key, field).to_i
50
+ end
51
+
52
+ def all_completed_count
53
+ if goals.empty?
54
+ completed_count
55
+ else
56
+ goals.inject(completed_count) do |sum, g|
57
+ sum + completed_count(g)
58
+ end
59
+ end
60
+ end
61
+
62
+ def unfinished_count
63
+ participant_count - all_completed_count
64
+ end
65
+
66
+ def set_field(goal)
67
+ field = "completed_count"
68
+ field += ":" + goal unless goal.nil?
69
+ return field
70
+ end
71
+
72
+ def set_prob_field(goal)
73
+ field = "p_winner"
74
+ field += ":" + goal unless goal.nil?
75
+ return field
76
+ end
77
+
78
+ def set_completed_count(count, goal = nil)
79
+ field = set_field(goal)
80
+ Split.redis.hset(key, field, count.to_i)
81
+ end
82
+
83
+ def increment_participation
84
+ Split.redis.hincrby key, 'participant_count', 1
85
+ end
86
+
87
+ def increment_completion(goal = nil)
88
+ field = set_field(goal)
89
+ Split.redis.hincrby(key, field, 1)
90
+ end
91
+
92
+ def control?
93
+ experiment.control.name == self.name
94
+ end
95
+
96
+ def conversion_rate(goal = nil)
97
+ return 0 if participant_count.zero?
98
+ (completed_count(goal).to_f)/participant_count.to_f
99
+ end
100
+
101
+ def experiment
102
+ Split::ExperimentCatalog.find(experiment_name)
103
+ end
104
+
105
+ def z_score(goal = nil)
106
+ # p_a = Pa = proportion of users who converted within the experiment split (conversion rate)
107
+ # p_c = Pc = proportion of users who converted within the control split (conversion rate)
108
+ # n_a = Na = the number of impressions within the experiment split
109
+ # n_c = Nc = the number of impressions within the control split
110
+
111
+ control = experiment.control
112
+ alternative = self
113
+
114
+ return 'N/A' if control.name == alternative.name
115
+
116
+ p_a = alternative.conversion_rate(goal)
117
+ p_c = control.conversion_rate(goal)
118
+
119
+ n_a = alternative.participant_count
120
+ n_c = control.participant_count
121
+
122
+ # can't calculate zscore for P(x) > 1
123
+ return 'N/A' if p_a > 1 || p_c > 1
124
+
125
+ Split::Zscore.calculate(p_a, n_a, p_c, n_c)
126
+ end
127
+
128
+ def extra_info
129
+ data = Split.redis.hget(key, 'recorded_info')
130
+ if data && data.length > 1
131
+ begin
132
+ JSON.parse(data)
133
+ rescue
134
+ {}
135
+ end
136
+ else
137
+ {}
138
+ end
139
+ end
140
+
141
+ def record_extra_info(k, value = 1)
142
+ @recorded_info = self.extra_info || {}
143
+
144
+ if value.kind_of?(Numeric)
145
+ @recorded_info[k] ||= 0
146
+ @recorded_info[k] += value
147
+ else
148
+ @recorded_info[k] = value
149
+ end
150
+
151
+ Split.redis.hset key, 'recorded_info', (@recorded_info || {}).to_json
152
+ end
153
+
154
+ def save
155
+ Split.redis.hsetnx key, 'participant_count', 0
156
+ Split.redis.hsetnx key, 'completed_count', 0
157
+ Split.redis.hsetnx key, 'p_winner', p_winner
158
+ Split.redis.hsetnx key, 'recorded_info', (@recorded_info || {}).to_json
159
+ end
160
+
161
+ def validate!
162
+ unless String === @name || hash_with_correct_values?(@name)
163
+ raise ArgumentError, 'Alternative must be a string'
164
+ end
165
+ end
166
+
167
+ def reset
168
+ Split.redis.hmset key, 'participant_count', 0, 'completed_count', 0, 'recorded_info', nil
169
+ unless goals.empty?
170
+ goals.each do |g|
171
+ field = "completed_count:#{g}"
172
+ Split.redis.hset key, field, 0
173
+ end
174
+ end
175
+ end
176
+
177
+ def delete
178
+ Split.redis.del(key)
179
+ end
180
+
181
+ private
182
+
183
+ def hash_with_correct_values?(name)
184
+ Hash === name && String === name.keys.first && Float(name.values.first) rescue false
185
+ end
186
+
187
+ def key
188
+ "#{experiment_name}:#{name}"
189
+ end
190
+ end
191
+ end