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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Split
4
+ module Persistence
5
+ class DualAdapter
6
+ def self.with_config(options={})
7
+ self.config.merge!(options)
8
+ self
9
+ end
10
+
11
+ def self.config
12
+ @config ||= {}
13
+ end
14
+
15
+ def initialize(context)
16
+ if logged_in = self.class.config[:logged_in]
17
+ else
18
+ raise "Please configure :logged_in"
19
+ end
20
+ if logged_in_adapter = self.class.config[:logged_in_adapter]
21
+ else
22
+ raise "Please configure :logged_in_adapter"
23
+ end
24
+ if logged_out_adapter = self.class.config[:logged_out_adapter]
25
+ else
26
+ raise "Please configure :logged_out_adapter"
27
+ end
28
+
29
+ @fallback_to_logged_out_adapter =
30
+ self.class.config[:fallback_to_logged_out_adapter] || false
31
+ @logged_in = logged_in.call(context)
32
+ @logged_in_adapter = logged_in_adapter.new(context)
33
+ @logged_out_adapter = logged_out_adapter.new(context)
34
+ @active_adapter = @logged_in ? @logged_in_adapter : @logged_out_adapter
35
+ end
36
+
37
+ def keys
38
+ if @fallback_to_logged_out_adapter
39
+ (@logged_in_adapter.keys + @logged_out_adapter.keys).uniq
40
+ else
41
+ @active_adapter.keys
42
+ end
43
+ end
44
+
45
+ def [](key)
46
+ if @fallback_to_logged_out_adapter
47
+ @logged_in && @logged_in_adapter[key] || @logged_out_adapter[key]
48
+ else
49
+ @active_adapter[key]
50
+ end
51
+ end
52
+
53
+ def []=(key, value)
54
+ if @fallback_to_logged_out_adapter
55
+ @logged_in_adapter[key] = value if @logged_in
56
+ old_value = @logged_out_adapter[key]
57
+ @logged_out_adapter[key] = value
58
+
59
+ decrement_participation(key, old_value) if decrement_participation?(old_value, value)
60
+ else
61
+ @active_adapter[key] = value
62
+ end
63
+ end
64
+
65
+ def delete(key)
66
+ if @fallback_to_logged_out_adapter
67
+ @logged_in_adapter.delete(key)
68
+ @logged_out_adapter.delete(key)
69
+ else
70
+ @active_adapter.delete(key)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def decrement_participation?(old_value, value)
77
+ !old_value.nil? && !value.nil? && old_value != value
78
+ end
79
+
80
+ def decrement_participation(key, value)
81
+ Split.redis.hincrby("#{key}:#{value}", 'participant_count', -1)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ module Persistence
4
+ class RedisAdapter
5
+ DEFAULT_CONFIG = {:namespace => 'persistence'}.freeze
6
+
7
+ attr_reader :redis_key
8
+
9
+ def initialize(context, key = nil)
10
+ if key
11
+ @redis_key = "#{self.class.config[:namespace]}:#{key}"
12
+ elsif lookup_by = self.class.config[:lookup_by]
13
+ if lookup_by.respond_to?(:call)
14
+ key_frag = lookup_by.call(context)
15
+ else
16
+ key_frag = context.send(lookup_by)
17
+ end
18
+ @redis_key = "#{self.class.config[:namespace]}:#{key_frag}"
19
+ else
20
+ raise "Please configure lookup_by"
21
+ end
22
+ end
23
+
24
+ def [](field)
25
+ Split.redis.hget(redis_key, field)
26
+ end
27
+
28
+ def []=(field, value)
29
+ Split.redis.hset(redis_key, field, value)
30
+ expire_seconds = self.class.config[:expire_seconds]
31
+ Split.redis.expire(redis_key, expire_seconds) if expire_seconds
32
+ end
33
+
34
+ def delete(field)
35
+ Split.redis.hdel(redis_key, field)
36
+ end
37
+
38
+ def keys
39
+ Split.redis.hkeys(redis_key)
40
+ end
41
+
42
+ def self.with_config(options={})
43
+ self.config.merge!(options)
44
+ self
45
+ end
46
+
47
+ def self.config
48
+ @config ||= DEFAULT_CONFIG.dup
49
+ end
50
+
51
+ def self.reset_config!
52
+ @config = DEFAULT_CONFIG.dup
53
+ end
54
+
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ module Persistence
4
+ class SessionAdapter
5
+
6
+ def initialize(context)
7
+ @session = context.session
8
+ @session[:split] ||= {}
9
+ end
10
+
11
+ def [](key)
12
+ @session[:split][key]
13
+ end
14
+
15
+ def []=(key, value)
16
+ @session[:split][key] = value
17
+ end
18
+
19
+ def delete(key)
20
+ @session[:split].delete(key)
21
+ end
22
+
23
+ def keys
24
+ @session[:split].keys
25
+ end
26
+
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ # Simplifies the interface to Redis.
4
+ class RedisInterface
5
+ def initialize
6
+ self.redis = Split.redis
7
+ end
8
+
9
+ def persist_list(list_name, list_values)
10
+ max_index = list_length(list_name) - 1
11
+ list_values.each_with_index do |value, index|
12
+ if index > max_index
13
+ add_to_list(list_name, value)
14
+ else
15
+ set_list_index(list_name, index, value)
16
+ end
17
+ end
18
+ make_list_length(list_name, list_values.length)
19
+ list_values
20
+ end
21
+
22
+ def add_to_list(list_name, value)
23
+ redis.rpush(list_name, value)
24
+ end
25
+
26
+ def set_list_index(list_name, index, value)
27
+ redis.lset(list_name, index, value)
28
+ end
29
+
30
+ def list_length(list_name)
31
+ redis.llen(list_name)
32
+ end
33
+
34
+ def remove_last_item_from_list(list_name)
35
+ redis.rpop(list_name)
36
+ end
37
+
38
+ def make_list_length(list_name, new_length)
39
+ redis.ltrim(list_name, 0, new_length - 1)
40
+ end
41
+
42
+ def add_to_set(set_name, value)
43
+ redis.sadd(set_name, value) unless redis.sismember(set_name, value)
44
+ end
45
+
46
+ private
47
+
48
+ attr_accessor :redis
49
+ end
50
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class Trial
4
+ attr_accessor :experiment
5
+ attr_writer :metadata
6
+
7
+ def initialize(attrs = {})
8
+ self.experiment = attrs.delete(:experiment)
9
+ self.alternative = attrs.delete(:alternative)
10
+ self.metadata = attrs.delete(:metadata)
11
+
12
+ @user = attrs.delete(:user)
13
+ @options = attrs
14
+
15
+ @alternative_choosen = false
16
+ end
17
+
18
+ def metadata
19
+ @metadata ||= experiment.metadata[alternative.name] if experiment.metadata
20
+ end
21
+
22
+ def alternative
23
+ @alternative ||= if @experiment.has_winner?
24
+ @experiment.winner
25
+ end
26
+ end
27
+
28
+ def alternative=(alternative)
29
+ @alternative = if alternative.kind_of?(Split::Alternative)
30
+ alternative
31
+ else
32
+ @experiment.alternatives.find{|a| a.name == alternative }
33
+ end
34
+ end
35
+
36
+ def complete!(goals=[], context = nil)
37
+ if alternative
38
+ if Array(goals).empty?
39
+ alternative.increment_completion
40
+ else
41
+ Array(goals).each {|g| alternative.increment_completion(g) }
42
+ end
43
+
44
+ run_callback context, Split.configuration.on_trial_complete
45
+ end
46
+ end
47
+
48
+ # Choose an alternative, add a participant, and save the alternative choice on the user. This
49
+ # method is guaranteed to only run once, and will skip the alternative choosing process if run
50
+ # a second time.
51
+ def choose!(context = nil)
52
+ @user.cleanup_old_experiments!
53
+ # Only run the process once
54
+ return alternative if @alternative_choosen
55
+
56
+ if override_is_alternative?
57
+ self.alternative = @options[:override]
58
+ if should_store_alternative? && !@user[@experiment.key]
59
+ self.alternative.increment_participation
60
+ end
61
+ elsif @options[:disabled] || Split.configuration.disabled?
62
+ self.alternative = @experiment.control
63
+ elsif @experiment.has_winner?
64
+ self.alternative = @experiment.winner
65
+ else
66
+ cleanup_old_versions
67
+
68
+ if exclude_user?
69
+ self.alternative = @experiment.control
70
+ else
71
+ self.alternative = @user[@experiment.key]
72
+ if alternative.nil?
73
+ self.alternative = @experiment.next_alternative
74
+
75
+ # Increment the number of participants since we are actually choosing a new alternative
76
+ self.alternative.increment_participation
77
+
78
+ run_callback context, Split.configuration.on_trial_choose
79
+ end
80
+ end
81
+ end
82
+
83
+ @user[@experiment.key] = alternative.name if !@experiment.has_winner? && should_store_alternative?
84
+ @alternative_choosen = true
85
+ run_callback context, Split.configuration.on_trial unless @options[:disabled] || Split.configuration.disabled?
86
+ alternative
87
+ end
88
+
89
+ private
90
+
91
+ def run_callback(context, callback_name)
92
+ context.send(callback_name, self) if callback_name && context.respond_to?(callback_name, true)
93
+ end
94
+
95
+ def override_is_alternative?
96
+ @experiment.alternatives.map(&:name).include?(@options[:override])
97
+ end
98
+
99
+ def should_store_alternative?
100
+ if @options[:override] || @options[:disabled]
101
+ Split.configuration.store_override
102
+ else
103
+ !exclude_user?
104
+ end
105
+ end
106
+
107
+ def cleanup_old_versions
108
+ if @experiment.version > 0
109
+ @user.cleanup_old_versions!(@experiment)
110
+ end
111
+ end
112
+
113
+ def exclude_user?
114
+ @options[:exclude] || @experiment.start_time.nil? || @user.max_experiments_reached?(@experiment.key)
115
+ end
116
+ end
117
+ end
data/lib/split/user.rb ADDED
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+ require 'forwardable'
3
+
4
+ module Split
5
+ class User
6
+ extend Forwardable
7
+ def_delegators :@user, :keys, :[], :[]=, :delete
8
+ attr_reader :user
9
+
10
+ def initialize(context, adapter=nil)
11
+ @user = adapter || Split::Persistence.adapter.new(context)
12
+ @cleaned_up = false
13
+ end
14
+
15
+ def cleanup_old_experiments!
16
+ return if @cleaned_up
17
+ keys_without_finished(user.keys).each do |key|
18
+ experiment = ExperimentCatalog.find key_without_version(key)
19
+ if experiment.nil? || experiment.has_winner? || experiment.start_time.nil?
20
+ user.delete key
21
+ user.delete Experiment.finished_key(key)
22
+ end
23
+ end
24
+ @cleaned_up = true
25
+ end
26
+
27
+ def max_experiments_reached?(experiment_key)
28
+ if Split.configuration.allow_multiple_experiments == 'control'
29
+ experiments = active_experiments
30
+ count_control = experiments.count {|k,v| k == experiment_key || v == 'control'}
31
+ experiments.size > count_control
32
+ else
33
+ !Split.configuration.allow_multiple_experiments &&
34
+ keys_without_experiment(user.keys, experiment_key).length > 0
35
+ end
36
+ end
37
+
38
+ def cleanup_old_versions!(experiment)
39
+ keys = user.keys.select { |k| k.match(Regexp.new(experiment.name)) }
40
+ keys_without_experiment(keys, experiment.key).each { |key| user.delete(key) }
41
+ end
42
+
43
+ def active_experiments
44
+ experiment_pairs = {}
45
+ keys_without_finished(user.keys).each do |key|
46
+ Metric.possible_experiments(key_without_version(key)).each do |experiment|
47
+ if !experiment.has_winner?
48
+ experiment_pairs[key_without_version(key)] = user[key]
49
+ end
50
+ end
51
+ end
52
+ experiment_pairs
53
+ end
54
+
55
+ private
56
+
57
+ def keys_without_experiment(keys, experiment_key)
58
+ keys.reject { |k| k.match(Regexp.new("^#{experiment_key}(:finished)?$")) }
59
+ end
60
+
61
+ def keys_without_finished(keys)
62
+ keys.reject { |k| k.include?(":finished") }
63
+ end
64
+
65
+ def key_without_version(key)
66
+ key.split(/\:\d(?!\:)/)[0]
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ MAJOR = 1
4
+ MINOR = 0
5
+ PATCH = 0
6
+ VERSION = [MAJOR, MINOR, PATCH].join('.')
7
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ module Split
3
+ class Zscore
4
+
5
+ include Math
6
+
7
+ def self.calculate(p1, n1, p2, n2)
8
+ # p_1 = Pa = proportion of users who converted within the experiment split (conversion rate)
9
+ # p_2 = Pc = proportion of users who converted within the control split (conversion rate)
10
+ # n_1 = Na = the number of impressions within the experiment split
11
+ # n_2 = Nc = the number of impressions within the control split
12
+ # s_1 = SEa = standard error of p_1, the estiamte of the mean
13
+ # s_2 = SEc = standard error of p_2, the estimate of the control
14
+ # s_p = SEp = standard error of p_1 - p_2, assuming a pooled variance
15
+ # s_unp = SEunp = standard error of p_1 - p_2, assuming unpooled variance
16
+
17
+ p_1 = p1.to_f
18
+ p_2 = p2.to_f
19
+
20
+ n_1 = n1.to_f
21
+ n_2 = n2.to_f
22
+
23
+ # Perform checks on data to make sure we can validly run our confidence tests
24
+ if n_1 < 30 || n_2 < 30
25
+ error = "Needs 30+ participants."
26
+ return error
27
+ elsif p_1 * n_1 < 5 || p_2 * n_2 < 5
28
+ error = "Needs 5+ conversions."
29
+ return error
30
+ end
31
+
32
+ # Formula for standard error: root(pq/n) = root(p(1-p)/n)
33
+ s_1 = Math.sqrt((p_1)*(1-p_1)/(n_1))
34
+ s_2 = Math.sqrt((p_2)*(1-p_2)/(n_2))
35
+
36
+ # Formula for pooled error of the difference of the means: root(π*(1-π)*(1/na+1/nc)
37
+ # π = (xa + xc) / (na + nc)
38
+ pi = (p_1*n_1 + p_2*n_2)/(n_1 + n_2)
39
+ s_p = Math.sqrt(pi*(1-pi)*(1/n_1 + 1/n_2))
40
+
41
+ # Formula for unpooled error of the difference of the means: root(sa**2/na + sc**2/nc)
42
+ s_unp = Math.sqrt(s_1**2 + s_2**2)
43
+
44
+ # Boolean variable decides whether we can pool our variances
45
+ pooled = s_1/s_2 < 2 && s_2/s_1 < 2
46
+
47
+ # Assign standard error either the pooled or unpooled variance
48
+ se = pooled ? s_p : s_unp
49
+
50
+ # Calculate z-score
51
+ z_score = (p_1 - p_2)/(se)
52
+
53
+ return z_score
54
+
55
+ end
56
+ end
57
+ end