vinted-ab 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: dbd33f286fd10d67783cfd1faaea45625555ef81
4
+ data.tar.gz: df941afba5ffb850c9e7f2b6efaef828c54a313a
5
+ SHA512:
6
+ metadata.gz: 1ab9db9647bd6b49f2aa3cbf85e59673db57dc3a7148ac6f9f5cd4decaf85535676a07d5b77620a59ed1f259b603b4b37bb1269545d7d4cff76f9f68318cd872
7
+ data.tar.gz: e51da312da7e11e9bdb12d51b69547efa81bc4351ddabc2b1a0006ce9244b9874fe24c0acd9b1bb50103af859aa9bb42ac3475fb24eb12077aa5227d06352355
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # ab
2
+
3
+ ## Configuration
4
+
5
+ For this lib to work, it requires a configuration json, which looks like this:
6
+
7
+ ```json
8
+ {
9
+ "salt": "534979417dc75a6f6f49146603a5e17e",
10
+ "bucket_count": 1000,
11
+ "ab_tests": [
12
+ {
13
+ "id": 42,
14
+ "name": "experiment",
15
+ "start_at": "2014-05-21T11:06:30+03:00",
16
+ "end_at": "2014-05-28T11:06:30+03:00",
17
+ "seed": "aaaa1111",
18
+ "buckets": [
19
+ 1,
20
+ 2,
21
+ 3,
22
+ 4,
23
+ 5
24
+ ],
25
+ "variants": [
26
+ {
27
+ "name": "green_button",
28
+ "chance_weight": 1
29
+ },
30
+ {
31
+ "name": "red_button",
32
+ "chance_weight": 2
33
+ },
34
+ {
35
+ "name": "control",
36
+ "chance_weight": 3
37
+ }
38
+ ]
39
+ },
40
+ {
41
+ "id": 44,
42
+ "name": "red_shirts",
43
+ "start_at": "2014-05-24T11:09:30+03:00",
44
+ "end_at": "2099-05-24T11:09:30+03:00",
45
+ "seed": "bbbb4444",
46
+ "buckets": [
47
+ 4,
48
+ 5,
49
+ 6,
50
+ 7
51
+ ],
52
+ "variants": [
53
+ {
54
+ "name": "red_shirt",
55
+ "chance_weight": 1
56
+ },
57
+ {
58
+ "name": "control",
59
+ "chance_weight": 1
60
+ }
61
+ ]
62
+ },
63
+ {
64
+ "id": 47,
65
+ "name": "feed",
66
+ "start_at": "1999-03-31T00:00:00+03:00",
67
+ "end_at": "2099-03-31T00:00:00+03:00",
68
+ "seed": "cccc8888",
69
+ "buckets": "all",
70
+ "variants": [
71
+ {
72
+ "name": "enabled",
73
+ "chance_weight": 1
74
+ }
75
+ ]
76
+ },
77
+ ]
78
+ }
79
+ ```
80
+
81
+ ## Usage
82
+
83
+ ```ruby
84
+ configuration = retrieve_from_svc_abs
85
+ ab = Ab::Experiments.new(configuration, user_id)
86
+
87
+ # defining callbacks
88
+ Ab::Experiments.before_picking_variant { |experiment| puts 'magic' }
89
+ Ab::Experiments.after_picking_variant { |experiment, variant| puts "#{variant_name}" }
90
+
91
+ # ab.experiment never returns nil
92
+ # but if you don't belong to any of the buckets, variant will be nil
93
+ case ab.experiment.variant
94
+ when 'red_button'
95
+ red_button
96
+ when 'green_button'
97
+ green_button
98
+ else
99
+ blue_button
100
+ end
101
+
102
+ # calls #variant underneath
103
+ # #variant caches results, so meta tracking events would not be sent multiple times
104
+ puts 'magic' if ab.experiment.red_button?
105
+
106
+ render_feed if ab.feed.enabled?
107
+ ```
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
data/ab.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'ab/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'vinted-ab'
8
+ spec.version = Ab::VERSION
9
+ spec.platform = Gem::Platform::RUBY
10
+ spec.authors = ['Mindaugas Mozūras', 'Gintaras Sakalauskas']
11
+ spec.email = ['mindaugas.mozuras@gmail.com', 'gintaras.sakalauskas@gmail.com']
12
+ spec.homepage = 'https://github.com/vinted/ab'
13
+ spec.summary = 'AB testing gem used internally by Vinted'
14
+
15
+ spec.required_rubygems_version = '>= 1.3.6'
16
+ spec.license = 'MIT'
17
+
18
+ spec.files = `git ls-files`.split($/)
19
+ spec.test_files = `git ls-files -- {spec}/*`.split("\n")
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.add_dependency 'hooks', '~> 0.4.0'
23
+ spec.add_development_dependency 'bundler', '~> 1.3'
24
+ spec.add_development_dependency 'rake', '~> 10.1.0'
25
+ spec.add_development_dependency 'rspec', '~> 2.14.0'
26
+ end
data/lib/ab.rb ADDED
@@ -0,0 +1,7 @@
1
+ require 'hooks'
2
+ require 'ab/version'
3
+ require 'ab/null_experiment'
4
+ require 'ab/variant'
5
+ require 'ab/experiment'
6
+ require 'ab/assigned_experiment'
7
+ require 'ab/experiments'
@@ -0,0 +1,54 @@
1
+ module Ab
2
+ class AssignedExperiment
3
+ include Hooks
4
+ define_hooks :before_picking_variant, :after_picking_variant
5
+
6
+ def initialize(experiment, id)
7
+ @experiment, @id = experiment, id
8
+ @experiment.variants.map(&:name).each do |name|
9
+ define_singleton_method("#{name}?") { name == variant }
10
+ end
11
+ end
12
+
13
+ def variant
14
+ @variant ||= begin
15
+ return unless part_of_experiment?
16
+ return unless running?
17
+
18
+ run_hook :before_picking_variant, @experiment.name
19
+ picked_variant = @experiment.variants.find { |v| v.accumulated_chance_weight > weight_id }
20
+
21
+ result = picked_variant.name if picked_variant
22
+ run_hook :after_picking_variant, @experiment.name, result
23
+ result
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def part_of_experiment?
30
+ @experiment.buckets == 'all' || @experiment.buckets.include?(bucket_id)
31
+ end
32
+
33
+ def bucket_id
34
+ @bucket_id ||= digest(@experiment.salt + @id.to_s) % @experiment.bucket_count
35
+ end
36
+
37
+ def running?
38
+ now = DateTime.now
39
+ now.between?(@experiment.start_at, @experiment.end_at)
40
+ end
41
+
42
+ def weight_id
43
+ @variant_digest ||= digest(@experiment.seed + @id.to_s) % positive_weight_sum
44
+ end
45
+
46
+ def positive_weight_sum
47
+ @experiment.weight_sum > 0 ? @experiment.weight_sum : 1
48
+ end
49
+
50
+ def digest(string)
51
+ Digest::SHA256.hexdigest(string).to_i(16)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,36 @@
1
+ module Ab
2
+ class Experiment < Struct.new(:hash, :salt, :bucket_count)
3
+ def buckets
4
+ hash['buckets']
5
+ end
6
+
7
+ def name
8
+ hash['name']
9
+ end
10
+
11
+ def variants
12
+ @variants ||= begin
13
+ accumulated = 0
14
+ hash['variants'].map do |variant_hash|
15
+ Variant.new(variant_hash, accumulated += variant_hash['chance_weight'] )
16
+ end
17
+ end
18
+ end
19
+
20
+ def seed
21
+ hash['seed']
22
+ end
23
+
24
+ def start_at
25
+ @start_at ||= DateTime.parse(hash['start_at'])
26
+ end
27
+
28
+ def end_at
29
+ @end_at ||= hash['end_at'].nil? ? DateTime.now.next_year : DateTime.parse(hash['end_at'])
30
+ end
31
+
32
+ def weight_sum
33
+ variants.map(&:chance_weight).inject(:+)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ module Ab
2
+ class Experiments
3
+ include Hooks
4
+ define_hooks :before_picking_variant, :after_picking_variant
5
+
6
+ Ab::AssignedExperiment.before_picking_variant do |experiment|
7
+ Ab::Experiments.run_hook :before_picking_variant, experiment
8
+ end
9
+ Ab::AssignedExperiment.after_picking_variant do |experiment, variant|
10
+ Ab::Experiments.run_hook :after_picking_variant, experiment, variant
11
+ end
12
+
13
+ def initialize(config, id)
14
+ @assigned_experiments ||= {}
15
+
16
+ (config['ab_tests'] || []).each do |experiment|
17
+ name = experiment['name']
18
+ define_singleton_method(name) do
19
+ experiment = Experiment.new(experiment, config['salt'], config['bucket_count'])
20
+ @assigned_experiments[name] ||= AssignedExperiment.new(experiment, id)
21
+ end
22
+ end
23
+ end
24
+
25
+ def method_missing(meth, *args, &block)
26
+ @null_experiment ||= NullExperiment.new
27
+ end
28
+
29
+ def respond_to?(meth)
30
+ true
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ class NullExperiment
2
+ def variant
3
+ nil
4
+ end
5
+
6
+ def method_missing(meth, *args, &block)
7
+ meth.to_s.end_with?('?') ? false : super
8
+ end
9
+
10
+ def respond_to?(meth)
11
+ meth.to_s.end_with?('?') ? true : super
12
+ end
13
+ end
data/lib/ab/variant.rb ADDED
@@ -0,0 +1,9 @@
1
+ class Variant < Struct.new(:hash, :accumulated_chance_weight)
2
+ def chance_weight
3
+ hash['chance_weight']
4
+ end
5
+
6
+ def name
7
+ hash['name']
8
+ end
9
+ end
data/lib/ab/version.rb ADDED
@@ -0,0 +1,3 @@
1
+ module Ab
2
+ VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,89 @@
1
+ require 'spec_helper'
2
+
3
+ module Ab
4
+ describe AssignedExperiment do
5
+ let(:assigned_experiment) { AssignedExperiment.new(experiment, id) }
6
+ let(:experiment) { Experiment.new(hash, 'e131bfcfcab06c65d633d0266c7e62a4918', 1000) }
7
+ let(:hash) {
8
+ {
9
+ 'name' => name,
10
+ 'start_at' => start_at,
11
+ 'end_at' => end_at,
12
+ 'buckets' => buckets,
13
+ 'seed' => seed,
14
+ 'variants' => variants
15
+ }
16
+ }
17
+ let(:id) { 1 }
18
+ let(:name) { 'feed' }
19
+ let(:start_at) { DateTime.now.prev_year.to_s }
20
+ let(:end_at) { DateTime.now.next_year.to_s }
21
+ let(:seed) { 'cccc8888' }
22
+ let(:buckets) { 'all' }
23
+ let(:thousand_variants) { 1.upto(1000).map { |i| AssignedExperiment.new(experiment, i).variant } }
24
+
25
+ describe '#variant' do
26
+ subject { assigned_experiment.variant }
27
+
28
+ context 'single variant' do
29
+ let(:variants) { [{ 'name' => 'enabled', 'chance_weight' => chance_weight }] }
30
+
31
+ context 'that is turned off' do
32
+ let(:chance_weight) { 0 }
33
+ it { should be_nil }
34
+ end
35
+
36
+ context 'that is turned on with 1' do
37
+ let(:chance_weight) { 1 }
38
+ it { should == 'enabled' }
39
+ end
40
+
41
+ context 'that is turned on with 111' do
42
+ let(:chance_weight) { 111 }
43
+ it { should == 'enabled' }
44
+ end
45
+ end
46
+
47
+ context 'single variant with buckets' do
48
+ let(:buckets) { (1..100) }
49
+ let(:variants) { [{ 'name' => 'enabled', 'chance_weight' => 1 }] }
50
+
51
+ specify 'half the ids fall under one, other under other' do
52
+ enabled = thousand_variants.select { |variant| variant == 'enabled' }
53
+ enabled.count.should be_within(20).of(100)
54
+ end
55
+ end
56
+
57
+ context 'experiment that has not started yet' do
58
+ let(:start_at) { DateTime.now.next_year.to_s }
59
+ let(:buckets) { [1, 2, 3] }
60
+ let(:variants) { [{ 'name' => 'enabled', 'chance_weight' => 1 }] }
61
+ it { should be_nil }
62
+ end
63
+
64
+ context 'experiment that has already ended' do
65
+ let(:end_at) { DateTime.now.prev_year.to_s }
66
+ let(:buckets) { [1, 2, 3] }
67
+ let(:variants) { [{ 'name' => 'enabled', 'chance_weight' => 1 }] }
68
+ it { should be_nil }
69
+ end
70
+
71
+ context 'two variants' do
72
+ let(:name) { 'button' }
73
+ let(:variants) {
74
+ [
75
+ { 'name' => 'red', 'chance_weight' => 1 },
76
+ { 'name' => 'blue', 'chance_weight' => 1 }
77
+ ]
78
+ }
79
+
80
+ specify 'half the ids fall under one, other under other' do
81
+ reds = thousand_variants.select { |variant| variant == 'red' }
82
+ blues = thousand_variants.select { |variant| variant == 'blue' }
83
+ reds.count.should be_within(20).of(500)
84
+ blues.count.should be_within(20).of(500)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,36 @@
1
+ require 'spec_helper'
2
+
3
+ module Ab
4
+ describe Experiments do
5
+ describe '.new' do
6
+ subject { Experiments.new(config, id) }
7
+ let(:id) { 1 }
8
+
9
+ context 'empty config' do
10
+ let(:config) { {} }
11
+
12
+ specify 'has no public methods' do
13
+ (subject.public_methods(false) - [:method_missing, :respond_to?]).count.should == 0
14
+ end
15
+
16
+ specify 'does not raise if method is not existant' do
17
+ expect{ subject.bla_bla_bla }.to_not raise_error
18
+ end
19
+ end
20
+
21
+ context 'single experiment with single variant' do
22
+ let(:config) {
23
+ {
24
+ 'salt' => 'anything',
25
+ 'bucket_count' => 1000,
26
+ 'ab_tests' => [{
27
+ 'name' => 'feed',
28
+ 'variants' => [{ 'name' => 'enabled', 'chance_weight' => 1 }]
29
+ }]
30
+ }
31
+ }
32
+ its(:feed) { should be_kind_of AssignedExperiment }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,15 @@
1
+ require 'spec_helper'
2
+
3
+ module Ab
4
+ describe NullExperiment do
5
+ subject { NullExperiment.new }
6
+
7
+ specify 'does not raise for method ending in question mark' do
8
+ expect{ subject.bla? }.to_not raise_error
9
+ end
10
+
11
+ specify 'raises for method not ending in question mark' do
12
+ expect{ subject.bla }.to raise_error
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ require 'rspec'
2
+ require 'ab'
3
+
4
+ RSpec.configure do |config|
5
+ config.color_enabled = true
6
+ config.treat_symbols_as_metadata_keys_with_true_values = true
7
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vinted-ab
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Mindaugas Mozūras
8
+ - Gintaras Sakalauskas
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2014-05-23 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: hooks
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ~>
19
+ - !ruby/object:Gem::Version
20
+ version: 0.4.0
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ~>
26
+ - !ruby/object:Gem::Version
27
+ version: 0.4.0
28
+ - !ruby/object:Gem::Dependency
29
+ name: bundler
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ~>
33
+ - !ruby/object:Gem::Version
34
+ version: '1.3'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ~>
40
+ - !ruby/object:Gem::Version
41
+ version: '1.3'
42
+ - !ruby/object:Gem::Dependency
43
+ name: rake
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ~>
47
+ - !ruby/object:Gem::Version
48
+ version: 10.1.0
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ~>
54
+ - !ruby/object:Gem::Version
55
+ version: 10.1.0
56
+ - !ruby/object:Gem::Dependency
57
+ name: rspec
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ~>
61
+ - !ruby/object:Gem::Version
62
+ version: 2.14.0
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ~>
68
+ - !ruby/object:Gem::Version
69
+ version: 2.14.0
70
+ description:
71
+ email:
72
+ - mindaugas.mozuras@gmail.com
73
+ - gintaras.sakalauskas@gmail.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - .gitignore
79
+ - Gemfile
80
+ - LICENSE
81
+ - README.md
82
+ - Rakefile
83
+ - ab.gemspec
84
+ - lib/ab.rb
85
+ - lib/ab/assigned_experiment.rb
86
+ - lib/ab/experiment.rb
87
+ - lib/ab/experiments.rb
88
+ - lib/ab/null_experiment.rb
89
+ - lib/ab/variant.rb
90
+ - lib/ab/version.rb
91
+ - spec/assigned_experiment_spec.rb
92
+ - spec/experiments_spec.rb
93
+ - spec/null_experiment_spec.rb
94
+ - spec/spec_helper.rb
95
+ homepage: https://github.com/vinted/ab
96
+ licenses:
97
+ - MIT
98
+ metadata: {}
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - '>='
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - '>='
111
+ - !ruby/object:Gem::Version
112
+ version: 1.3.6
113
+ requirements: []
114
+ rubyforge_project:
115
+ rubygems_version: 2.2.2
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: AB testing gem used internally by Vinted
119
+ test_files: []