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