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